diff --git a/ncm_mcp_servers/Dockerfile b/ncm_mcp_servers/Dockerfile new file mode 100644 index 0000000..3d02fa6 --- /dev/null +++ b/ncm_mcp_servers/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY pyproject.toml . +COPY __init__.py ./ncm_mcp_servers/ +COPY shared/ ./ncm_mcp_servers/shared/ +COPY ncm_fleet/ ./ncm_mcp_servers/ncm_fleet/ +COPY ncm_monitoring/ ./ncm_mcp_servers/ncm_monitoring/ +COPY ncm_cloud_services/ ./ncm_mcp_servers/ncm_cloud_services/ +COPY entrypoint.sh /app/entrypoint.sh + +RUN pip install --no-cache-dir -e . && chmod +x /app/entrypoint.sh + +ENV MCP_TRANSPORT=streamable-http +ENV NCM_FLEET_PORT=3001 +ENV NCM_MONITORING_PORT=3002 +ENV NCM_CLOUD_SERVICES_PORT=3003 + +EXPOSE 3001 3002 3003 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/ncm_mcp_servers/README.md b/ncm_mcp_servers/README.md new file mode 100644 index 0000000..ccd08db --- /dev/null +++ b/ncm_mcp_servers/README.md @@ -0,0 +1,240 @@ +# NCM MCP Servers + +![NCM MCP Servers](assets/screenshot.png) + +A suite of three focused MCP (Model Context Protocol) servers for the Ericsson Enterprise Wireless NCM API, split by domain responsibility for optimal LLM tool selection. + +## Architecture + +| Server | Port | Domain | Tools | +|--------|------|--------|-------| +| `ncm-fleet` | 3001 | Routers, groups, accounts, locations, configurations, firmware, products | 17 | +| `ncm-monitoring` | 3002 | Net devices, alerts/logs, speed tests | 6 | +| `ncm-cloud-services` | 3003 | Users, subscriptions, private cellular, exchange | 13 | + +All servers default to **Streamable HTTP transport** and run together in a single container. + +## Quick Start + +### 1. Install + +```bash +cd ncm_mcp_servers +pip install -e . +``` + +### 2. Configure Credentials + +Copy the example and fill in your values: + +```bash +cp credentials.example.json credentials.json +``` + +```json +{ + "X_CP_API_ID": "your-cp-api-id", + "X_CP_API_KEY": "your-cp-api-key", + "X_ECM_API_ID": "your-ecm-api-id", + "X_ECM_API_KEY": "your-ecm-api-key", + "NCM_API_TOKEN": "your-v3-bearer-token" +} +``` + +Alternatively, set environment variables with the same names. + +### 3. Run (Docker — recommended) + +```bash +docker compose up --build +``` + +Or build and run manually: + +```bash +docker build -t ncm-mcp-servers . +docker run -p 3001:3001 -p 3002:3002 -p 3003:3003 \ + -v ./credentials.json:/app/credentials.json:ro \ + ncm-mcp-servers +``` + +This starts a single container running all 3 servers: +- `http://localhost:3001/mcp` — ncm-fleet +- `http://localhost:3002/mcp` — ncm-monitoring +- `http://localhost:3003/mcp` — ncm-cloud-services + +### 4. Run (Local, without Docker) + +```bash +# Run all 3 servers in a single process (recommended for local dev) +ncm-mcp-servers + +# Or via python module +python -m ncm_mcp_servers +``` + +Run servers individually if you only need a subset: + +```bash +ncm-fleet # port 3001 +ncm-monitoring # port 3002 +ncm-cloud-services # port 3003 +``` + +Override ports via environment variables: +```bash +NCM_FLEET_PORT=4001 ncm-fleet +NCM_MONITORING_PORT=4002 ncm-monitoring +NCM_CLOUD_SERVICES_PORT=4003 ncm-cloud-services +``` + +### 5. Transport Options + +The default transport is **Streamable HTTP**. Supported transports: + +| Transport | Value | Use case | +|-----------|-------|----------| +| Streamable HTTP | `streamable-http` (default) | Recommended for all MCP clients | +| SSE | `sse` | Legacy MCP clients using Server-Sent Events | +| Stdio | `stdio` | Piped MCP clients (single server only) | + +```bash +# Use SSE transport (legacy) +MCP_TRANSPORT=sse ncm-mcp-servers + +# Use stdio (only works with individual servers, not the unified runner) +MCP_TRANSPORT=stdio ncm-fleet +``` + +## MCP Client Configuration + +Add to your MCP settings (e.g. `.kiro/settings/mcp.json`): + +```json +{ + "mcpServers": { + "ncm-fleet": { + "url": "http://localhost:3001/mcp" + }, + "ncm-monitoring": { + "url": "http://localhost:3002/mcp" + }, + "ncm-cloud-services": { + "url": "http://localhost:3003/mcp" + } + } +} +``` + +## Tool Consolidation Strategy + +CRUD operations are consolidated into `manage_*` tools with an `action` parameter: + +```python +manage_router(action="rename", router_id=123, new_name="foo") +manage_router(action="delete", router_id=123) +manage_router(action="update", router_id=123, description="bar") +``` + +Read-only metrics are consolidated by type: + +```python +get_net_device_metrics(metric_type="signal", net_device=1) +get_net_device_metrics(metric_type="usage", net_device=1) +get_net_device_metrics(metric_type="wan", net_device=1) +get_net_device_metrics(metric_type="modem", net_device=1) +``` + +## Project Structure + +``` +ncm_mcp_servers/ +├── pyproject.toml +├── Dockerfile +├── docker-compose.yml +├── entrypoint.sh +├── credentials.example.json +├── credentials.json # Your credentials (gitignored) +├── ncm_mcp_servers/ +│ ├── shared/ # Common utilities +│ │ ├── ncm.py # NCM API client library +│ │ ├── credentials.py # Credential loading +│ │ ├── error_handler.py # Response/error formatting +│ │ └── client.py # NCM client factory +│ ├── ncm_fleet/ # Fleet management server (port 3001) +│ │ ├── server.py +│ │ └── tools/ +│ │ ├── routers.py +│ │ ├── groups.py +│ │ ├── accounts.py +│ │ ├── locations.py +│ │ ├── configurations.py +│ │ └── firmware.py +│ ├── ncm_monitoring/ # Monitoring server (port 3002) +│ │ ├── server.py +│ │ └── tools/ +│ │ ├── net_devices.py +│ │ ├── alerts.py +│ │ └── speed_tests.py +│ └── ncm_cloud_services/ # Cloud services server (port 3003) +│ ├── server.py +│ └── tools/ +│ ├── users.py +│ ├── subscriptions.py +│ ├── private_cellular.py +│ └── exchange.py +└── README.md +``` + +## Tool Reference + +### ncm-fleet (17 tools) + +| Tool | Description | +|------|-------------| +| `get_routers` | Query routers by ID, name, account, or group | +| `manage_router` | Rename, delete, update fields, assign to group/account, remove from group | +| `reboot_router` | Reboot a single router | +| `reboot_group` | Reboot all routers in a group | +| `get_groups` | Query groups by ID, name, or account | +| `manage_group` | Create, rename, delete, update groups | +| `get_accounts` | Query accounts by ID or name | +| `manage_subaccount` | Create, rename, delete subaccounts | +| `get_locations` | Query current or historical locations | +| `manage_location` | Create or delete router locations | +| `get_configuration_managers` | Query config sync status | +| `patch_config` | Partial config update (router or group) | +| `put_router_config` | Full config replacement | +| `copy_router_config` | Copy config between routers | +| `resume_updates` | Resume suspended config sync | +| `get_firmware` | Query firmware versions by product | +| `get_products` | Query product catalog | + +### ncm-monitoring (6 tools) + +| Tool | Description | +|------|-------------| +| `get_net_devices` | Query net devices by router, mode, connection state | +| `get_net_device_health` | Get cellular health scores | +| `get_net_device_metrics` | Signal, usage, WAN, or modem metrics | +| `get_logs` | Alerts, recent alerts, router logs, or activity logs | +| `create_speed_test` | Run speed test on devices or all modems on a router | +| `get_speed_test` | Get speed test status and results | + +### ncm-cloud-services (13 tools) + +| Tool | Description | +|------|-------------| +| `get_users` | Query NCM users | +| `manage_user` | Create, update, delete users, change role | +| `get_subscriptions` | Query subscriptions | +| `manage_subscription` | Regrade or unlicense devices | +| `get_networks` | Query private cellular networks | +| `manage_network` | Create, update, delete networks | +| `get_radios` | Query radios and operational status | +| `manage_radio` | Update radio settings | +| `manage_sim` | Query or update SIMs | +| `get_exchange_sites` | Query exchange sites | +| `manage_exchange_site` | Create, update, delete sites | +| `get_exchange_resources` | Query exchange resources | +| `manage_exchange_resource` | Create, update, delete resources | diff --git a/ncm_mcp_servers/__init__.py b/ncm_mcp_servers/__init__.py new file mode 100644 index 0000000..8aaeb18 --- /dev/null +++ b/ncm_mcp_servers/__init__.py @@ -0,0 +1,7 @@ +"""NCM MCP Servers — a suite of focused MCP servers for Cradlepoint NCM. + +This package contains three specialized MCP servers: +- ncm_fleet: Router, group, account, location, configuration, and firmware management +- ncm_monitoring: Net device monitoring, alerts/logs, and speed tests +- ncm_cloud_services: Users, subscriptions, private cellular, and NetCloud Exchange +""" diff --git a/ncm_mcp_servers/__main__.py b/ncm_mcp_servers/__main__.py new file mode 100644 index 0000000..ab424e5 --- /dev/null +++ b/ncm_mcp_servers/__main__.py @@ -0,0 +1,103 @@ +"""Unified runner — starts all three NCM MCP servers concurrently. + +Usage: + python -m ncm_mcp_servers + +All servers run in a single process using asyncio. Each binds to its +own port (default: 3001, 3002, 3003). Transport defaults to Streamable HTTP. + +Environment variables: + MCP_TRANSPORT: "streamable-http" (default) or "sse" + NCM_FLEET_PORT: Fleet server port (default 3001) + NCM_MONITORING_PORT: Monitoring server port (default 3002) + NCM_CLOUD_SERVICES_PORT: Cloud services server port (default 3003) + LOG_LEVEL: Logging level (default INFO) +""" + +import asyncio +import os +import signal +import sys +from typing import cast + +from ncm_mcp_servers.shared.credentials import load_credentials +from ncm_mcp_servers.shared.logging import get_logger + +logger = get_logger("runner") + + +async def run_all() -> None: + """Start all three MCP servers concurrently.""" + credentials = load_credentials() + transport = os.environ.get("MCP_TRANSPORT", "streamable-http").lower() + + logger.info( + "Starting all NCM MCP servers", + extra={"transport": transport}, + ) + + # Import here to avoid circular import issues at module level + from ncm_mcp_servers.ncm_fleet.server import create_server as create_fleet + from ncm_mcp_servers.ncm_monitoring.server import create_server as create_monitoring + from ncm_mcp_servers.ncm_cloud_services.server import create_server as create_cloud + + fleet = create_fleet(credentials) + monitoring = create_monitoring(credentials) + cloud = create_cloud(credentials) + + # Select the async runner based on transport + if transport == "sse": + tasks = [ + asyncio.create_task(fleet.run_sse_async()), + asyncio.create_task(monitoring.run_sse_async()), + asyncio.create_task(cloud.run_sse_async()), + ] + else: + # Default: Streamable HTTP + tasks = [ + asyncio.create_task(fleet.run_streamable_http_async()), + asyncio.create_task(monitoring.run_streamable_http_async()), + asyncio.create_task(cloud.run_streamable_http_async()), + ] + + fleet_port = os.environ.get("NCM_FLEET_PORT", "3001") + monitoring_port = os.environ.get("NCM_MONITORING_PORT", "3002") + cloud_port = os.environ.get("NCM_CLOUD_SERVICES_PORT", "3003") + + logger.info(f" ncm-fleet -> port {fleet_port}") + logger.info(f" ncm-monitoring -> port {monitoring_port}") + logger.info(f" ncm-cloud-services -> port {cloud_port}") + + # Wait for all tasks; if one fails, cancel the rest + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) + + for task in pending: + task.cancel() + + # Re-raise any exceptions from completed tasks + for task in done: + if task.exception(): + raise task.exception() + + +def main() -> None: + """Entry point for unified server runner.""" + # Handle Ctrl+C gracefully + def handle_signal(sig, frame): + logger.info("Shutting down...") + sys.exit(0) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + try: + asyncio.run(run_all()) + except ValueError as exc: + logger.error("Failed to start servers", extra={"error": str(exc)}) + sys.exit(1) + except KeyboardInterrupt: + logger.info("Shutting down...") + + +if __name__ == "__main__": + main() diff --git a/ncm_mcp_servers/assets/screenshot.png b/ncm_mcp_servers/assets/screenshot.png new file mode 100644 index 0000000..d07a8c0 Binary files /dev/null and b/ncm_mcp_servers/assets/screenshot.png differ diff --git a/ncm_mcp_servers/credentials.example.json b/ncm_mcp_servers/credentials.example.json new file mode 100644 index 0000000..6f83b2e --- /dev/null +++ b/ncm_mcp_servers/credentials.example.json @@ -0,0 +1,7 @@ +{ + "X_CP_API_ID": "your-cp-api-id", + "X_CP_API_KEY": "your-cp-api-key", + "X_ECM_API_ID": "your-ecm-api-id", + "X_ECM_API_KEY": "your-ecm-api-key", + "NCM_API_TOKEN": "your-v3-bearer-token" +} diff --git a/ncm_mcp_servers/docker-compose.yml b/ncm_mcp_servers/docker-compose.yml new file mode 100644 index 0000000..a2b0e63 --- /dev/null +++ b/ncm_mcp_servers/docker-compose.yml @@ -0,0 +1,14 @@ +services: + ncm-mcp: + build: . + ports: + - "3001:3001" + - "3002:3002" + - "3003:3003" + environment: + - MCP_TRANSPORT=streamable-http + - NCM_FLEET_PORT=3001 + - NCM_MONITORING_PORT=3002 + - NCM_CLOUD_SERVICES_PORT=3003 + volumes: + - ./credentials.json:/app/credentials.json:ro diff --git a/ncm_mcp_servers/entrypoint.sh b/ncm_mcp_servers/entrypoint.sh new file mode 100644 index 0000000..79af058 --- /dev/null +++ b/ncm_mcp_servers/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Start all three NCM MCP servers in a single container. +# Each server runs on its own port (default: 3001, 3002, 3003). +set -e + +PIDS=() + +# Trap SIGTERM/SIGINT and forward to child processes +cleanup() { + echo "Received shutdown signal. Stopping servers..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null + done + wait + exit 0 +} +trap cleanup SIGTERM SIGINT + +echo "Starting NCM MCP Servers..." +echo " ncm-fleet -> port ${NCM_FLEET_PORT:-3001}" +echo " ncm-monitoring -> port ${NCM_MONITORING_PORT:-3002}" +echo " ncm-cloud-services -> port ${NCM_CLOUD_SERVICES_PORT:-3003}" + +python -m ncm_mcp_servers.ncm_fleet & +PIDS+=($!) + +python -m ncm_mcp_servers.ncm_monitoring & +PIDS+=($!) + +python -m ncm_mcp_servers.ncm_cloud_services & +PIDS+=($!) + +# Wait for any process to exit +wait -n +EXIT_CODE=$? + +echo "A server process exited (code=$EXIT_CODE). Shutting down..." +cleanup diff --git a/ncm_mcp_servers/ncm_cloud_services/__init__.py b/ncm_mcp_servers/ncm_cloud_services/__init__.py new file mode 100644 index 0000000..7e8fce4 --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/__init__.py @@ -0,0 +1,5 @@ +"""NCM Cloud Services MCP Server. + +Manages users, subscriptions, private cellular networks, and NetCloud Exchange +via the NCM v3 API. +""" diff --git a/ncm_mcp_servers/ncm_cloud_services/__main__.py b/ncm_mcp_servers/ncm_cloud_services/__main__.py new file mode 100644 index 0000000..2d6af74 --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/__main__.py @@ -0,0 +1,3 @@ +"""Allow running with ``python -m ncm_mcp_servers.ncm_cloud_services``.""" +from ncm_mcp_servers.ncm_cloud_services.server import main +main() diff --git a/ncm_mcp_servers/ncm_cloud_services/server.py b/ncm_mcp_servers/ncm_cloud_services/server.py new file mode 100644 index 0000000..9622531 --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/server.py @@ -0,0 +1,65 @@ +"""NCM Cloud Services MCP Server entry point. + +Manages users, subscriptions, private cellular networks, and +NetCloud Exchange via the NCM v3 API. + +Tools (13 total): +- get_users, manage_user +- get_subscriptions, manage_subscription +- get_networks, manage_network, get_radios, manage_radio, manage_sim +- get_exchange_sites, manage_exchange_site, get_exchange_resources, + manage_exchange_resource +""" + +import os +import sys +from typing import Optional, cast + +from mcp.server.fastmcp import FastMCP + +from ncm_mcp_servers.shared.credentials import ApiCredentials, load_credentials +from ncm_mcp_servers.shared.client import get_ncm_client +from ncm_mcp_servers.shared.logging import get_logger +from ncm_mcp_servers.ncm_cloud_services.tools import register_all + +logger = get_logger("ncm_cloud_services") + + +def create_server( + credentials: Optional[ApiCredentials] = None, +) -> FastMCP: + """Creates and configures the cloud services MCP server.""" + if credentials is None: + credentials = load_credentials() + + credentials.validate(require_v3=True) + client = get_ncm_client(credentials) + v3_client = client.v3 if hasattr(client, 'v3') else client + + transport = os.environ.get("MCP_TRANSPORT", "streamable-http").lower() + port = int(os.environ.get("NCM_CLOUD_SERVICES_PORT", "3003")) + + if transport == "sse": + mcp = FastMCP("ncm-cloud-services", host="0.0.0.0", port=port) + else: + mcp = FastMCP("ncm-cloud-services", host="0.0.0.0", port=port) + + register_all(mcp, v3_client) # type: ignore[arg-type] + logger.info("Server configured", extra={"transport": transport, "port": port}) + return mcp + + +def main() -> None: + """Entry point for ncm-cloud-services server.""" + try: + server = create_server() + transport = cast(str, os.environ.get("MCP_TRANSPORT", "streamable-http")).lower() + logger.info("Starting ncm-cloud-services server") + server.run(transport=transport) # type: ignore[arg-type] + except ValueError as exc: + logger.error("Failed to start server", extra={"error": str(exc)}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ncm_mcp_servers/ncm_cloud_services/tools/__init__.py b/ncm_mcp_servers/ncm_cloud_services/tools/__init__.py new file mode 100644 index 0000000..6683a31 --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/tools/__init__.py @@ -0,0 +1,22 @@ +"""NCM Cloud Services tools registration.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from ncm_mcp_servers.shared.ncm import NcmClientv3 + +from ncm_mcp_servers.ncm_cloud_services.tools import ( + users, + subscriptions, + private_cellular, + exchange, +) + + +def register_all(mcp: "FastMCP", client: "NcmClientv3") -> None: + """Registers all cloud services tools with the MCP server.""" + users.register(mcp, client) + subscriptions.register(mcp, client) + private_cellular.register(mcp, client) + exchange.register(mcp, client) diff --git a/ncm_mcp_servers/ncm_cloud_services/tools/exchange.py b/ncm_mcp_servers/ncm_cloud_services/tools/exchange.py new file mode 100644 index 0000000..51ef6c7 --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/tools/exchange.py @@ -0,0 +1,296 @@ +"""MCP tools for NCM exchange site and resource management. + +Consolidates exchange operations into: +- get_exchange_sites: Query exchange sites +- manage_exchange_site: Create, update, delete sites +- get_exchange_resources: Query exchange resources +- manage_exchange_resource: Create, update, delete resources +""" + +from typing import List, Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register exchange management tools.""" + + @mcp.tool() + def get_exchange_sites( + site_id: Optional[str] = None, + exchange_network_id: Optional[str] = None, + name: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve exchange sites with optional filtering by ID, name, or network.""" + try: + kwargs = {} + if limit is not None: + kwargs["limit"] = limit + result = client.get_exchange_sites( + site_id=site_id, + exchange_network_id=exchange_network_id, + name=name, + **kwargs, + ) + return handle_ncm_response(result, "get_exchange_sites") + except Exception as e: + return handle_exception(e, "get_exchange_sites") + + @mcp.tool() + def manage_exchange_site( + action: str = None, + site_id: Optional[str] = None, + site_name: Optional[str] = None, + name: Optional[str] = None, + exchange_network_id: Optional[str] = None, + router_id: Optional[str] = None, + primary_dns: Optional[str] = None, + secondary_dns: Optional[str] = None, + lan_as_dns: Optional[bool] = None, + local_domain: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> dict: + """Manage exchange site lifecycle operations. + + Actions: + - "create": Create a new site. Requires name, exchange_network_id, router_id. + Optional: primary_dns, secondary_dns, lan_as_dns, local_domain, tags. + - "update": Update a site. Requires site_id or name. + Optional: primary_dns, secondary_dns, lan_as_dns, local_domain, tags. + - "delete": Delete a site. Requires site_id or site_name. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "create": + err = validate_required_params( + name=name, + exchange_network_id=exchange_network_id, + router_id=router_id, + ) + if err is not None: + return err + kwargs = {} + if primary_dns is not None: + kwargs["primary_dns"] = primary_dns + if secondary_dns is not None: + kwargs["secondary_dns"] = secondary_dns + if lan_as_dns is not None: + kwargs["lan_as_dns"] = lan_as_dns + if local_domain is not None: + kwargs["local_domain"] = local_domain + if tags is not None: + kwargs["tags"] = tags + result = client.create_exchange_site( + name, exchange_network_id, router_id, **kwargs + ) + + elif action == "update": + if site_id is None and name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either site_id or name", + }, + "operation": "manage_exchange_site", + } + kwargs = {} + if primary_dns is not None: + kwargs["primary_dns"] = primary_dns + if secondary_dns is not None: + kwargs["secondary_dns"] = secondary_dns + if lan_as_dns is not None: + kwargs["lan_as_dns"] = lan_as_dns + if local_domain is not None: + kwargs["local_domain"] = local_domain + if tags is not None: + kwargs["tags"] = tags + result = client.update_exchange_site( + site_id=site_id, name=name, **kwargs + ) + + elif action == "delete": + if site_id is None and site_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either site_id or site_name", + }, + "operation": "manage_exchange_site", + } + result = client.delete_exchange_site( + site_id=site_id, site_name=site_name + ) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, update, delete", + }, + "operation": "manage_exchange_site", + } + + return handle_ncm_response(result, "manage_exchange_site") + except Exception as e: + return handle_exception(e, "manage_exchange_site") + + @mcp.tool() + def get_exchange_resources( + site_id: Optional[str] = None, + exchange_network_id: Optional[str] = None, + resource_id: Optional[str] = None, + site_name: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve exchange resources with optional filtering.""" + try: + kwargs = {} + if limit is not None: + kwargs["limit"] = limit + result = client.get_exchange_resources( + site_id=site_id, + exchange_network_id=exchange_network_id, + resource_id=resource_id, + site_name=site_name, + **kwargs, + ) + return handle_ncm_response(result, "get_exchange_resources") + except Exception as e: + return handle_exception(e, "get_exchange_resources") + + @mcp.tool() + def manage_exchange_resource( + action: str = None, + resource_id: Optional[str] = None, + resource_name: Optional[str] = None, + resource_type: Optional[str] = None, + site_id: Optional[str] = None, + site_name: Optional[str] = None, + domain: Optional[str] = None, + ip: Optional[str] = None, + protocols: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + static_prime_ip: Optional[str] = None, + port_ranges: Optional[List[str]] = None, + name: Optional[str] = None, + ) -> dict: + """Manage exchange resource lifecycle operations. + + Actions: + - "create": Create a resource. Requires resource_name, resource_type, + (site_id or site_name). resource_type must be one of: + exchange_fqdn_resources, exchange_wildcard_fqdn_resources, + or exchange_ipsubnet_resources. + Optional: domain, ip, protocols, tags, static_prime_ip, port_ranges. + - "update": Update a resource. Requires resource_id. + Optional: name, protocols, tags, domain, ip, static_prime_ip, port_ranges. + - "delete": Delete resources. Requires resource_id, site_name, or site_id. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "create": + err = validate_required_params( + resource_name=resource_name, resource_type=resource_type + ) + if err is not None: + return err + if site_id is None and site_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either site_id or site_name", + }, + "operation": "manage_exchange_resource", + } + kwargs = {} + if domain is not None: + kwargs["domain"] = domain + if ip is not None: + kwargs["ip"] = ip + if protocols is not None: + kwargs["protocols"] = protocols + if tags is not None: + kwargs["tags"] = tags + if static_prime_ip is not None: + kwargs["static_prime_ip"] = static_prime_ip + if port_ranges is not None: + kwargs["port_ranges"] = port_ranges + result = client.create_exchange_resource( + resource_name, + resource_type, + site_id=site_id, + site_name=site_name, + **kwargs, + ) + + elif action == "update": + err = validate_required_params(resource_id=resource_id) + if err is not None: + return err + kwargs = {} + if name is not None: + kwargs["name"] = name + if protocols is not None: + kwargs["protocols"] = protocols + if tags is not None: + kwargs["tags"] = tags + if domain is not None: + kwargs["domain"] = domain + if ip is not None: + kwargs["ip"] = ip + if static_prime_ip is not None: + kwargs["static_prime_ip"] = static_prime_ip + if port_ranges is not None: + kwargs["port_ranges"] = port_ranges + result = client.update_exchange_resource(resource_id, **kwargs) + + elif action == "delete": + if resource_id is None and site_name is None and site_id is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide resource_id, site_name, or site_id", + }, + "operation": "manage_exchange_resource", + } + result = client.delete_exchange_resource( + resource_id=resource_id, + site_name=site_name, + site_id=site_id, + ) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, update, delete", + }, + "operation": "manage_exchange_resource", + } + + return handle_ncm_response(result, "manage_exchange_resource") + except Exception as e: + return handle_exception(e, "manage_exchange_resource") diff --git a/ncm_mcp_servers/ncm_cloud_services/tools/private_cellular.py b/ncm_mcp_servers/ncm_cloud_services/tools/private_cellular.py new file mode 100644 index 0000000..3b4f0ee --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/tools/private_cellular.py @@ -0,0 +1,290 @@ +"""MCP tools for NCM private cellular network management. + +Consolidates private cellular operations into: +- get_networks: Query private cellular networks +- manage_network: Create, update, delete networks +- get_radios: Query radios and their statuses +- manage_radio: Update radio settings +- manage_sim: Query and update SIMs +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register private cellular management tools.""" + + @mcp.tool() + def get_networks( + network_id: Optional[str] = None, + name: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve private cellular networks with optional filtering.""" + try: + if network_id is not None: + result = client.get_private_cellular_network(network_id) + return handle_ncm_response(result, "get_networks") + kwargs = {} + if name is not None: + kwargs["name"] = name + if status is not None: + kwargs["status"] = status + if limit is not None: + kwargs["limit"] = limit + result = client.get_private_cellular_networks(**kwargs) + return handle_ncm_response(result, "get_networks") + except Exception as e: + return handle_exception(e, "get_networks") + + @mcp.tool() + def manage_network( + action: str = None, + network_id: Optional[str] = None, + name: Optional[str] = None, + core_ip: Optional[str] = None, + ha_enabled: Optional[bool] = None, + mobility_gateway_virtual_ip: Optional[str] = None, + mobility_gateways: Optional[str] = None, + ) -> dict: + """Manage private cellular network lifecycle. + + Actions: + - "create": Create a network. Requires name + core_ip. + Optional: ha_enabled, mobility_gateway_virtual_ip, mobility_gateways. + - "update": Update a network. Requires network_id or name. + Optional: core_ip, ha_enabled, mobility_gateway_virtual_ip. + - "delete": Delete a network. Requires network_id. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "create": + err = validate_required_params(name=name, core_ip=core_ip) + if err is not None: + return err + result = client.create_private_cellular_network( + name, + core_ip, + ha_enabled=ha_enabled if ha_enabled is not None else False, + mobility_gateway_virtual_ip=mobility_gateway_virtual_ip, + mobility_gateways=mobility_gateways, + ) + + elif action == "update": + if network_id is None and name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either network_id or name", + }, + "operation": "manage_network", + } + kwargs = {} + if core_ip is not None: + kwargs["core_ip"] = core_ip + if ha_enabled is not None: + kwargs["ha_enabled"] = ha_enabled + if mobility_gateway_virtual_ip is not None: + kwargs["mobility_gateway_virtual_ip"] = mobility_gateway_virtual_ip + result = client.update_private_cellular_network( + id=network_id, name=name, **kwargs + ) + + elif action == "delete": + err = validate_required_params(network_id=network_id) + if err is not None: + return err + result = client.delete_private_cellular_network(network_id) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, update, delete", + }, + "operation": "manage_network", + } + + return handle_ncm_response(result, "manage_network") + except Exception as e: + return handle_exception(e, "manage_network") + + @mcp.tool() + def get_radios( + radio_id: Optional[str] = None, + network: Optional[str] = None, + name: Optional[str] = None, + status: Optional[str] = None, + include_status: bool = False, + online_status: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve private cellular radios or their operational statuses. + + Args: + radio_id: Get a specific radio by ID. + network: Filter by network. + name: Filter by name. + status: Filter by admin state. + include_status: If True, retrieves operational status instead of radio config. + online_status: Filter operational status (only when include_status=True). + limit: Max results. + """ + try: + if include_status: + kwargs = {} + if online_status is not None: + kwargs["online_status"] = online_status + if limit is not None: + kwargs["limit"] = limit + if radio_id is not None: + result = client.get_private_cellular_radio_status(radio_id) + else: + result = client.get_private_cellular_radio_statuses(**kwargs) + else: + if radio_id is not None: + result = client.get_private_cellular_radio(radio_id) + else: + kwargs = {} + if network is not None: + kwargs["network"] = network + if name is not None: + kwargs["name"] = name + if status is not None: + kwargs["admin_state"] = status + if limit is not None: + kwargs["limit"] = limit + result = client.get_private_cellular_radios(**kwargs) + return handle_ncm_response(result, "get_radios") + except Exception as e: + return handle_exception(e, "get_radios") + + @mcp.tool() + def manage_radio( + radio_id: Optional[str] = None, + name: Optional[str] = None, + admin_state: Optional[str] = None, + description: Optional[str] = None, + network: Optional[str] = None, + tx_power: Optional[int] = None, + ) -> dict: + """Update a private cellular radio by ID or name. + + Requires radio_id or name to identify the radio. + Optional fields to update: admin_state, description, network, tx_power. + """ + try: + if radio_id is None and name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either radio_id or name", + }, + "operation": "manage_radio", + } + kwargs = {} + if admin_state is not None: + kwargs["admin_state"] = admin_state + if description is not None: + kwargs["description"] = description + if network is not None: + kwargs["network"] = network + if tx_power is not None: + kwargs["tx_power"] = tx_power + result = client.update_private_cellular_radio( + id=radio_id, name=name, **kwargs + ) + return handle_ncm_response(result, "manage_radio") + except Exception as e: + return handle_exception(e, "manage_radio") + + @mcp.tool() + def manage_sim( + action: str = None, + sim_id: Optional[str] = None, + iccid: Optional[str] = None, + imsi: Optional[str] = None, + network: Optional[str] = None, + name: Optional[str] = None, + state: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Query or update private cellular SIMs. + + Actions: + - "get": Retrieve SIMs. Optional filters: sim_id, network, iccid, name, limit. + - "update": Update a SIM. Requires sim_id, iccid, or imsi. + Optional: name, state, network. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "get": + if sim_id is not None: + result = client.get_private_cellular_sim(sim_id) + else: + kwargs = {} + if network is not None: + kwargs["network"] = network + if iccid is not None: + kwargs["iccid"] = iccid + if name is not None: + kwargs["name"] = name + if limit is not None: + kwargs["limit"] = limit + result = client.get_private_cellular_sims(**kwargs) + + elif action == "update": + if sim_id is None and iccid is None and imsi is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide sim_id, iccid, or imsi", + }, + "operation": "manage_sim", + } + kwargs = {} + if name is not None: + kwargs["name"] = name + if state is not None: + kwargs["state"] = state + if network is not None: + kwargs["network"] = network + result = client.update_private_cellular_sim( + id=sim_id, iccid=iccid, imsi=imsi, **kwargs + ) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: get, update", + }, + "operation": "manage_sim", + } + + return handle_ncm_response(result, "manage_sim") + except Exception as e: + return handle_exception(e, "manage_sim") diff --git a/ncm_mcp_servers/ncm_cloud_services/tools/subscriptions.py b/ncm_mcp_servers/ncm_cloud_services/tools/subscriptions.py new file mode 100644 index 0000000..d8e053b --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/tools/subscriptions.py @@ -0,0 +1,97 @@ +"""MCP tools for NCM subscription and licensing management. + +Provides: +- get_subscriptions: Query subscriptions +- manage_subscription: Upgrade/downgrade/unlicense devices +""" + +from typing import List, Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register subscription management tools.""" + + @mcp.tool() + def get_subscriptions( + subscription_id: Optional[str] = None, + name: Optional[str] = None, + tenant: Optional[str] = None, + subscription_type: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve subscriptions with optional filtering by ID, name, tenant, or type.""" + try: + kwargs = {} + if subscription_id is not None: + kwargs["id"] = subscription_id + if name is not None: + kwargs["name"] = name + if tenant is not None: + kwargs["tenant"] = tenant + if subscription_type is not None: + kwargs["type"] = subscription_type + if limit is not None: + kwargs["limit"] = limit + result = client.get_subscriptions(**kwargs) + return handle_ncm_response(result, "get_subscriptions") + except Exception as e: + return handle_exception(e, "get_subscriptions") + + @mcp.tool() + def manage_subscription( + action: str = None, + subscription_id: Optional[str] = None, + mac: Optional[str] = None, + mac_addresses: Optional[List[str]] = None, + regrade_action: Optional[str] = "UPGRADE", + ) -> dict: + """Manage device subscriptions and licensing. + + Actions: + - "regrade": Upgrade or downgrade a device subscription. + Requires subscription_id + mac. Optional: regrade_action + ("UPGRADE" or "DOWNGRADE", default "UPGRADE"). + - "unlicense": Remove licenses from devices. + Requires mac_addresses (list of MAC addresses). + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "regrade": + err = validate_required_params( + subscription_id=subscription_id, mac=mac + ) + if err is not None: + return err + result = client.regrade( + subscription_id, mac, action=regrade_action + ) + + elif action == "unlicense": + err = validate_required_params(mac_addresses=mac_addresses) + if err is not None: + return err + result = client.unlicense_devices(mac_addresses) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: regrade, unlicense", + }, + "operation": "manage_subscription", + } + + return handle_ncm_response(result, "manage_subscription") + except Exception as e: + return handle_exception(e, "manage_subscription") diff --git a/ncm_mcp_servers/ncm_cloud_services/tools/users.py b/ncm_mcp_servers/ncm_cloud_services/tools/users.py new file mode 100644 index 0000000..4fc2ab5 --- /dev/null +++ b/ncm_mcp_servers/ncm_cloud_services/tools/users.py @@ -0,0 +1,109 @@ +"""MCP tools for NCM user management. + +Consolidates user CRUD into: +- get_users: Query/list users +- manage_user: Create, update, delete, change role +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register user management tools.""" + + @mcp.tool() + def get_users( + email: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + is_active: Optional[bool] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve NCM users with optional filtering by email, name, or active status.""" + try: + kwargs = {} + if email is not None: + kwargs["email"] = email + if first_name is not None: + kwargs["first_name"] = first_name + if last_name is not None: + kwargs["last_name"] = last_name + if is_active is not None: + kwargs["is_active"] = is_active + if limit is not None: + kwargs["limit"] = limit + result = client.get_users(**kwargs) + return handle_ncm_response(result, "get_users") + except Exception as e: + return handle_exception(e, "get_users") + + @mcp.tool() + def manage_user( + action: str = None, + email: str = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + is_active: Optional[bool] = None, + new_role: Optional[str] = None, + ) -> dict: + """Manage NCM user lifecycle operations. + + Actions: + - "create": Create a new user. Requires email, first_name, last_name. + - "update": Update user details. Requires email + any of + first_name, last_name, is_active. + - "delete": Delete a user. Requires email. + - "change_role": Change user role. Requires email + new_role. + """ + try: + err = validate_required_params(action=action, email=email) + if err is not None: + return err + + if action == "create": + err = validate_required_params( + first_name=first_name, last_name=last_name + ) + if err is not None: + return err + result = client.create_user(email, first_name, last_name) + + elif action == "update": + kwargs = {} + if first_name is not None: + kwargs["first_name"] = first_name + if last_name is not None: + kwargs["last_name"] = last_name + if is_active is not None: + kwargs["is_active"] = is_active + result = client.update_user(email, **kwargs) + + elif action == "delete": + result = client.delete_user(email) + + elif action == "change_role": + err = validate_required_params(new_role=new_role) + if err is not None: + return err + result = client.update_user_role(email, new_role) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, update, delete, change_role", + }, + "operation": "manage_user", + } + + return handle_ncm_response(result, "manage_user") + except Exception as e: + return handle_exception(e, "manage_user") diff --git a/ncm_mcp_servers/ncm_fleet/__init__.py b/ncm_mcp_servers/ncm_fleet/__init__.py new file mode 100644 index 0000000..0285c20 --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/__init__.py @@ -0,0 +1,5 @@ +"""NCM Fleet Management MCP Server. + +Manages routers, groups, accounts, locations, configurations, firmware, +and products via the NCM v2 API. +""" diff --git a/ncm_mcp_servers/ncm_fleet/__main__.py b/ncm_mcp_servers/ncm_fleet/__main__.py new file mode 100644 index 0000000..6b2d979 --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/__main__.py @@ -0,0 +1,3 @@ +"""Allow running with ``python -m ncm_mcp_servers.ncm_fleet``.""" +from ncm_mcp_servers.ncm_fleet.server import main +main() diff --git a/ncm_mcp_servers/ncm_fleet/server.py b/ncm_mcp_servers/ncm_fleet/server.py new file mode 100644 index 0000000..cb1ff9c --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/server.py @@ -0,0 +1,68 @@ +"""NCM Fleet Management MCP Server entry point. + +Manages routers, groups, accounts, locations, configurations, +firmware, and products via the NCM v2 API. + +Tools (17 total): +- get_routers, manage_router, reboot_router, reboot_group +- get_groups, manage_group +- get_accounts, manage_subaccount +- get_locations, manage_location +- get_configuration_managers, patch_config, put_router_config, + copy_router_config, resume_updates +- get_firmware +- get_products +""" + +import os +import sys +from typing import Optional, cast + +from mcp.server.fastmcp import FastMCP + +from ncm_mcp_servers.shared.credentials import ApiCredentials, load_credentials +from ncm_mcp_servers.shared.client import get_ncm_client +from ncm_mcp_servers.shared.logging import get_logger +from ncm_mcp_servers.ncm_fleet.tools import register_all + +logger = get_logger("ncm_fleet") + + +def create_server( + credentials: Optional[ApiCredentials] = None, +) -> FastMCP: + """Creates and configures the fleet management MCP server.""" + if credentials is None: + credentials = load_credentials() + + credentials.validate(require_v2=True) + client = get_ncm_client(credentials) + v2_client = client.v2 if hasattr(client, 'v2') else client + + transport = os.environ.get("MCP_TRANSPORT", "streamable-http").lower() + port = int(os.environ.get("NCM_FLEET_PORT", "3001")) + + if transport == "sse": + mcp = FastMCP("ncm-fleet", host="0.0.0.0", port=port) + else: + mcp = FastMCP("ncm-fleet", host="0.0.0.0", port=port) + + register_all(mcp, v2_client) # type: ignore[arg-type] + logger.info("Server configured", extra={"transport": transport, "port": port}) + return mcp + + +def main() -> None: + """Entry point for ncm-fleet server.""" + try: + server = create_server() + transport = cast(str, os.environ.get("MCP_TRANSPORT", "streamable-http")).lower() + logger.info("Starting ncm-fleet server") + server.run(transport=transport) # type: ignore[arg-type] + except ValueError as exc: + logger.error("Failed to start server", extra={"error": str(exc)}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ncm_mcp_servers/ncm_fleet/tools/__init__.py b/ncm_mcp_servers/ncm_fleet/tools/__init__.py new file mode 100644 index 0000000..f82c971 --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/__init__.py @@ -0,0 +1,26 @@ +"""NCM Fleet Management tools registration.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from ncm_mcp_servers.shared.ncm import NcmClientv2 + +from ncm_mcp_servers.ncm_fleet.tools import ( + routers, + groups, + accounts, + locations, + configurations, + firmware, +) + + +def register_all(mcp: "FastMCP", client: "NcmClientv2") -> None: + """Registers all fleet management tools with the MCP server.""" + routers.register(mcp, client) + groups.register(mcp, client) + accounts.register(mcp, client) + locations.register(mcp, client) + configurations.register(mcp, client) + firmware.register(mcp, client) diff --git a/ncm_mcp_servers/ncm_fleet/tools/accounts.py b/ncm_mcp_servers/ncm_fleet/tools/accounts.py new file mode 100644 index 0000000..2ae39ec --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/accounts.py @@ -0,0 +1,133 @@ +"""MCP tools for NCM account management. + +Consolidates account CRUD operations into: +- get_accounts: Query/list accounts +- manage_subaccount: Create, rename, delete subaccounts +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register account management tools.""" + + @mcp.tool() + def get_accounts( + account_id: Optional[int] = None, + name: Optional[str] = None, + ) -> dict: + """Retrieve accounts with optional filtering by ID or name.""" + try: + kwargs = {} + if account_id is not None: + kwargs["id"] = account_id + if name is not None: + kwargs["name"] = name + result = client.get_accounts(**kwargs) + return handle_ncm_response(result, "get_accounts") + except Exception as e: + return handle_exception(e, "get_accounts") + + @mcp.tool() + def manage_subaccount( + action: str = None, + subaccount_id: Optional[int] = None, + subaccount_name: Optional[str] = None, + new_name: Optional[str] = None, + parent_account_id: Optional[int] = None, + parent_account_name: Optional[str] = None, + ) -> dict: + """Manage subaccount lifecycle operations. + + Actions: + - "create": Create a subaccount. Requires new_name + + (parent_account_id or parent_account_name). + - "rename": Rename a subaccount. Requires (subaccount_id or subaccount_name) + + new_name. + - "delete": Delete a subaccount. Requires subaccount_id or subaccount_name. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "create": + err = validate_required_params(new_name=new_name) + if err is not None: + return err + if parent_account_id is None and parent_account_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either parent_account_id or parent_account_name", + }, + "operation": "manage_subaccount", + } + if parent_account_id is not None: + result = client.create_subaccount_by_parent_id( + parent_account_id, new_name + ) + else: + result = client.create_subaccount_by_parent_name( + parent_account_name, new_name + ) + + elif action == "rename": + err = validate_required_params(new_name=new_name) + if err is not None: + return err + if subaccount_id is None and subaccount_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either subaccount_id or subaccount_name", + }, + "operation": "manage_subaccount", + } + if subaccount_id is not None: + result = client.rename_subaccount_by_id(subaccount_id, new_name) + else: + result = client.rename_subaccount_by_name( + subaccount_name, new_name + ) + + elif action == "delete": + if subaccount_id is None and subaccount_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either subaccount_id or subaccount_name", + }, + "operation": "manage_subaccount", + } + if subaccount_id is not None: + result = client.delete_subaccount_by_id(subaccount_id) + else: + result = client.delete_subaccount_by_name(subaccount_name) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, rename, delete", + }, + "operation": "manage_subaccount", + } + + return handle_ncm_response(result, "manage_subaccount") + except Exception as e: + return handle_exception(e, "manage_subaccount") diff --git a/ncm_mcp_servers/ncm_fleet/tools/configurations.py b/ncm_mcp_servers/ncm_fleet/tools/configurations.py new file mode 100644 index 0000000..01e5c55 --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/configurations.py @@ -0,0 +1,130 @@ +"""MCP tools for NCM configuration management. + +Provides: +- get_configuration_managers: Query config sync status +- patch_config: Apply partial config updates to a router or group +- put_router_config: Full config replacement for a router +- copy_router_config: Copy config between routers +- resume_updates: Resume suspended config sync +""" + +from typing import List, Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register configuration management tools.""" + + @mcp.tool() + def get_configuration_managers( + router: Optional[int] = None, + synched: Optional[bool] = None, + suspended: Optional[bool] = None, + ) -> dict: + """Retrieve configuration managers with optional filtering.""" + try: + kwargs = {} + if router is not None: + kwargs["router"] = router + if synched is not None: + kwargs["synched"] = synched + if suspended is not None: + kwargs["suspended"] = suspended + result = client.get_configuration_managers(**kwargs) + return handle_ncm_response(result, "get_configuration_managers") + except Exception as e: + return handle_exception(e, "get_configuration_managers") + + @mcp.tool() + def patch_config( + target: str = None, + target_id: int = None, + config_json: dict = None, + removals: Optional[List[str]] = None, + ) -> dict: + """Apply a partial configuration update to a router or group. + + Args: + target: "router" or "group". + target_id: ID of the router or group to update. + config_json: Dictionary of configuration updates to apply. + removals: Optional list of configuration paths to remove. + """ + try: + err = validate_required_params( + target=target, target_id=target_id, config_json=config_json + ) + if err is not None: + return err + + payload = { + "configuration": [config_json, removals if removals else []] + } + + if target == "router": + result = client.patch_configuration_managers(target_id, payload) + elif target == "group": + result = client.patch_group_configuration(target_id, payload) + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid target", + "details": "Valid targets: router, group", + }, + "operation": "patch_config", + } + + return handle_ncm_response(result, "patch_config") + except Exception as e: + return handle_exception(e, "patch_config") + + @mcp.tool() + def put_router_config( + router_id: int = None, + config_json: dict = None, + ) -> dict: + """Perform a full configuration replacement on a router.""" + try: + err = validate_required_params(router_id=router_id, config_json=config_json) + if err is not None: + return err + result = client.put_configuration_managers(router_id, config_json) + return handle_ncm_response(result, "put_router_config") + except Exception as e: + return handle_exception(e, "put_router_config") + + @mcp.tool() + def copy_router_config( + src_router_id: int = None, + dst_router_id: int = None, + ) -> dict: + """Copy configuration from one router to another.""" + try: + err = validate_required_params( + src_router_id=src_router_id, dst_router_id=dst_router_id + ) + if err is not None: + return err + result = client.copy_router_configuration(src_router_id, dst_router_id) + return handle_ncm_response(result, "copy_router_config") + except Exception as e: + return handle_exception(e, "copy_router_config") + + @mcp.tool() + def resume_updates(router_id: int = None) -> dict: + """Resume configuration sync for a router in suspended state.""" + try: + err = validate_required_params(router_id=router_id) + if err is not None: + return err + result = client.resume_updates_for_router(router_id) + return handle_ncm_response(result, "resume_updates") + except Exception as e: + return handle_exception(e, "resume_updates") diff --git a/ncm_mcp_servers/ncm_fleet/tools/firmware.py b/ncm_mcp_servers/ncm_fleet/tools/firmware.py new file mode 100644 index 0000000..736e00d --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/firmware.py @@ -0,0 +1,78 @@ +"""MCP tools for NCM firmware and product information. + +Consolidates firmware + products into a single tool: +- get_firmware: Query firmware versions, optionally by product +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register firmware and product tools.""" + + @mcp.tool() + def get_products( + product_id: Optional[int] = None, + product_name: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve products with optional filtering by ID or name. + + If product_id is provided, returns a single product by ID. + If product_name is provided, returns a single product by name. + Otherwise returns all products. + """ + try: + if product_id is not None: + result = client.get_product_by_id(product_id) + return handle_ncm_response(result, "get_products") + if product_name is not None: + result = client.get_product_by_name(product_name) + return handle_ncm_response(result, "get_products") + kwargs = {} + if limit is not None: + kwargs["limit"] = limit + result = client.get_products(**kwargs) + return handle_ncm_response(result, "get_products") + except Exception as e: + return handle_exception(e, "get_products") + + @mcp.tool() + def get_firmware( + version: Optional[str] = None, + product_id: Optional[int] = None, + product_name: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve firmware versions with optional filtering. + + If product_id or product_name is provided along with version, + retrieves firmware for that specific product and version. + Otherwise returns all firmware matching the version filter. + """ + try: + if version and (product_id or product_name): + if product_id is not None: + result = client.get_firmware_for_product_id_by_version( + product_id, version + ) + else: + result = client.get_firmware_for_product_name_by_version( + product_name, version + ) + else: + kwargs = {} + if version is not None: + kwargs["version"] = version + if limit is not None: + kwargs["limit"] = limit + result = client.get_firmwares(**kwargs) + return handle_ncm_response(result, "get_firmware") + except Exception as e: + return handle_exception(e, "get_firmware") diff --git a/ncm_mcp_servers/ncm_fleet/tools/groups.py b/ncm_mcp_servers/ncm_fleet/tools/groups.py new file mode 100644 index 0000000..cc9955d --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/groups.py @@ -0,0 +1,155 @@ +"""MCP tools for NCM group management. + +Consolidates group CRUD operations into: +- get_groups: Query/list groups +- manage_group: Create, rename, delete, update +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register group management tools.""" + + @mcp.tool() + def get_groups( + group_id: Optional[int] = None, + name: Optional[str] = None, + account: Optional[str] = None, + ) -> dict: + """Retrieve groups with optional filtering by ID, name, or account.""" + try: + kwargs = {} + if group_id is not None: + kwargs["id"] = group_id + if name is not None: + kwargs["name"] = name + if account is not None: + kwargs["account"] = account + result = client.get_groups(**kwargs) + return handle_ncm_response(result, "get_groups") + except Exception as e: + return handle_exception(e, "get_groups") + + @mcp.tool() + def manage_group( + action: str = None, + group_id: Optional[int] = None, + group_name: Optional[str] = None, + new_name: Optional[str] = None, + parent_account_id: Optional[int] = None, + parent_account_name: Optional[str] = None, + product_name: Optional[str] = None, + firmware_version: Optional[str] = None, + product: Optional[str] = None, + target_firmware: Optional[str] = None, + configuration: Optional[dict] = None, + ) -> dict: + """Manage group lifecycle operations. + + Actions: + - "create": Create a new group. Requires group_name + + (parent_account_id or parent_account_name). Optional: product_name, firmware_version. + - "rename": Rename a group. Requires (group_id or group_name) + new_name. + - "delete": Delete a group. Requires group_id or group_name. + - "update": Update group fields. Requires group_id. + Optional: new_name, product, target_firmware, configuration. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "create": + err = validate_required_params(group_name=group_name) + if err is not None: + return err + if parent_account_id is None and parent_account_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either parent_account_id or parent_account_name", + }, + "operation": "manage_group", + } + if parent_account_id is not None: + result = client.create_group_by_parent_id( + parent_account_id, group_name, product_name, firmware_version + ) + else: + result = client.create_group_by_parent_name( + parent_account_name, group_name, product_name, firmware_version + ) + + elif action == "rename": + err = validate_required_params(new_name=new_name) + if err is not None: + return err + if group_id is None and group_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either group_id or group_name", + }, + "operation": "manage_group", + } + if group_id is not None: + result = client.rename_group_by_id(group_id, new_name) + else: + result = client.rename_group_by_name(group_name, new_name) + + elif action == "delete": + if group_id is None and group_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either group_id or group_name", + }, + "operation": "manage_group", + } + if group_id is not None: + result = client.delete_group_by_id(group_id) + else: + result = client.delete_group_by_name(group_name) + + elif action == "update": + err = validate_required_params(group_id=group_id) + if err is not None: + return err + kwargs = {} + if new_name is not None: + kwargs["name"] = new_name + if product is not None: + kwargs["product"] = product + if target_firmware is not None: + kwargs["target_firmware"] = target_firmware + if configuration is not None: + kwargs["configuration"] = configuration + result = client.patch_group(group_id, **kwargs) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, rename, delete, update", + }, + "operation": "manage_group", + } + + return handle_ncm_response(result, "manage_group") + except Exception as e: + return handle_exception(e, "manage_group") diff --git a/ncm_mcp_servers/ncm_fleet/tools/locations.py b/ncm_mcp_servers/ncm_fleet/tools/locations.py new file mode 100644 index 0000000..577d9c8 --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/locations.py @@ -0,0 +1,109 @@ +"""MCP tools for NCM location management. + +Consolidates location operations into: +- get_locations: Query current and historical locations +- manage_location: Create or delete locations +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register location management tools.""" + + @mcp.tool() + def get_locations( + router: Optional[int] = None, + historical: bool = False, + created_at__gt: Optional[str] = None, + created_at__lte: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve current or historical locations for routers. + + Args: + router: Filter by router ID. + historical: If True, retrieves historical location data (requires router). + created_at__gt: Start date filter (ISO 8601). Only for historical. + created_at__lte: End date filter (ISO 8601). Only for historical. + limit: Max number of results to return. + """ + try: + if historical: + err = validate_required_params(router=router) + if err is not None: + return err + kwargs = {} + if created_at__gt is not None: + kwargs["created_at__gt"] = created_at__gt + if created_at__lte is not None: + kwargs["created_at__lte"] = created_at__lte + if limit is not None: + kwargs["limit"] = limit + result = client.get_historical_locations(router, **kwargs) + else: + kwargs = {} + if router is not None: + kwargs["router"] = router + if limit is not None: + kwargs["limit"] = limit + result = client.get_locations(**kwargs) + return handle_ncm_response(result, "get_locations") + except Exception as e: + return handle_exception(e, "get_locations") + + @mcp.tool() + def manage_location( + action: str = None, + router_id: int = None, + account_id: Optional[int] = None, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + ) -> dict: + """Manage router location assignments. + + Actions: + - "create": Create and assign a location. Requires router_id, + account_id, latitude, longitude. + - "delete": Remove the location from a router. Requires router_id. + """ + try: + err = validate_required_params(action=action, router_id=router_id) + if err is not None: + return err + + if action == "create": + err = validate_required_params( + account_id=account_id, + latitude=latitude, + longitude=longitude, + ) + if err is not None: + return err + result = client.create_location( + account_id, latitude, longitude, router_id + ) + + elif action == "delete": + result = client.delete_location_for_router(router_id) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": "Valid actions: create, delete", + }, + "operation": "manage_location", + } + + return handle_ncm_response(result, "manage_location") + except Exception as e: + return handle_exception(e, "manage_location") diff --git a/ncm_mcp_servers/ncm_fleet/tools/routers.py b/ncm_mcp_servers/ncm_fleet/tools/routers.py new file mode 100644 index 0000000..a833ce8 --- /dev/null +++ b/ncm_mcp_servers/ncm_fleet/tools/routers.py @@ -0,0 +1,196 @@ +"""MCP tools for NCM router management. + +Consolidates router CRUD operations into: +- get_routers: Query/list routers +- manage_router: Create, rename, delete, update fields, assign/remove from group/account +- reboot_router: Reboot a single router +- reboot_group: Reboot all routers in a group +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register router management tools.""" + + @mcp.tool() + def get_routers( + router_id: Optional[int] = None, + name: Optional[str] = None, + account: Optional[str] = None, + group: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve routers with optional filtering by ID, name, account, or group.""" + try: + kwargs = {} + if router_id is not None: + kwargs["id"] = router_id + if name is not None: + kwargs["name"] = name + if account is not None: + kwargs["account"] = account + if group is not None: + kwargs["group"] = group + if limit is not None: + kwargs["limit"] = limit + result = client.get_routers(**kwargs) + return handle_ncm_response(result, "get_routers") + except Exception as e: + return handle_exception(e, "get_routers") + + @mcp.tool() + def manage_router( + action: str = None, + router_id: Optional[int] = None, + router_name: Optional[str] = None, + new_name: Optional[str] = None, + description: Optional[str] = None, + asset_id: Optional[str] = None, + custom1: Optional[str] = None, + custom2: Optional[str] = None, + group_id: Optional[int] = None, + account_id: Optional[int] = None, + ) -> dict: + """Manage router lifecycle operations. + + Actions: + - "rename": Rename a router. Requires router_id or router_name + new_name. + - "delete": Delete a router. Requires router_id or router_name. + - "update": Update router fields. Requires router_id + any of + new_name, description, asset_id, custom1, custom2. + - "assign_to_group": Assign router to group. Requires router_id + group_id. + - "remove_from_group": Remove router from group. Requires router_id or router_name. + - "assign_to_account": Move router to account. Requires router_id + account_id. + """ + try: + err = validate_required_params(action=action) + if err is not None: + return err + + if action == "rename": + err = validate_required_params(new_name=new_name) + if err is not None: + return err + if router_id is None and router_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either router_id or router_name", + }, + "operation": "manage_router", + } + if router_id is not None: + result = client.rename_router_by_id(router_id, new_name) + else: + result = client.rename_router_by_name(router_name, new_name) + + elif action == "delete": + if router_id is None and router_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either router_id or router_name", + }, + "operation": "manage_router", + } + if router_id is not None: + result = client.delete_router_by_id(router_id) + else: + result = client.delete_router_by_name(router_name) + + elif action == "update": + err = validate_required_params(router_id=router_id) + if err is not None: + return err + kwargs = {} + if new_name is not None: + kwargs["name"] = new_name + if description is not None: + kwargs["description"] = description + if asset_id is not None: + kwargs["asset_id"] = asset_id + if custom1 is not None: + kwargs["custom1"] = custom1 + if custom2 is not None: + kwargs["custom2"] = custom2 + result = client.set_router_fields(router_id, **kwargs) + + elif action == "assign_to_group": + err = validate_required_params(router_id=router_id, group_id=group_id) + if err is not None: + return err + result = client.assign_router_to_group(router_id, group_id) + + elif action == "remove_from_group": + if router_id is None and router_name is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either router_id or router_name", + }, + "operation": "manage_router", + } + result = client.remove_router_from_group( + router_id=router_id, router_name=router_name + ) + + elif action == "assign_to_account": + err = validate_required_params(router_id=router_id, account_id=account_id) + if err is not None: + return err + result = client.assign_router_to_account(router_id, account_id) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid action", + "details": ( + "Valid actions: rename, delete, update, " + "assign_to_group, remove_from_group, assign_to_account" + ), + }, + "operation": "manage_router", + } + + return handle_ncm_response(result, "manage_router") + except Exception as e: + return handle_exception(e, "manage_router") + + @mcp.tool() + def reboot_router(router_id: int = None) -> dict: + """Reboot a single router by its ID.""" + try: + err = validate_required_params(router_id=router_id) + if err is not None: + return err + result = client.reboot_device(router_id) + return handle_ncm_response(result, "reboot_router") + except Exception as e: + return handle_exception(e, "reboot_router") + + @mcp.tool() + def reboot_group(group_id: int = None) -> dict: + """Reboot all routers in a group by group ID.""" + try: + err = validate_required_params(group_id=group_id) + if err is not None: + return err + result = client.reboot_group(group_id) + return handle_ncm_response(result, "reboot_group") + except Exception as e: + return handle_exception(e, "reboot_group") diff --git a/ncm_mcp_servers/ncm_monitoring/__init__.py b/ncm_mcp_servers/ncm_monitoring/__init__.py new file mode 100644 index 0000000..297cd01 --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/__init__.py @@ -0,0 +1,5 @@ +"""NCM Monitoring MCP Server. + +Monitors network health via net devices, alerts/logs, and speed tests +using the NCM v2 API. +""" diff --git a/ncm_mcp_servers/ncm_monitoring/__main__.py b/ncm_mcp_servers/ncm_monitoring/__main__.py new file mode 100644 index 0000000..a6b7653 --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/__main__.py @@ -0,0 +1,3 @@ +"""Allow running with ``python -m ncm_mcp_servers.ncm_monitoring``.""" +from ncm_mcp_servers.ncm_monitoring.server import main +main() diff --git a/ncm_mcp_servers/ncm_monitoring/server.py b/ncm_mcp_servers/ncm_monitoring/server.py new file mode 100644 index 0000000..ead6688 --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/server.py @@ -0,0 +1,63 @@ +"""NCM Monitoring MCP Server entry point. + +Monitors network health: net devices, alerts/logs, and speed tests +via the NCM v2 API. + +Tools (6 total): +- get_net_devices, get_net_device_health, get_net_device_metrics +- get_logs +- create_speed_test, get_speed_test +""" + +import os +import sys +from typing import Optional, cast + +from mcp.server.fastmcp import FastMCP + +from ncm_mcp_servers.shared.credentials import ApiCredentials, load_credentials +from ncm_mcp_servers.shared.client import get_ncm_client +from ncm_mcp_servers.shared.logging import get_logger +from ncm_mcp_servers.ncm_monitoring.tools import register_all + +logger = get_logger("ncm_monitoring") + + +def create_server( + credentials: Optional[ApiCredentials] = None, +) -> FastMCP: + """Creates and configures the monitoring MCP server.""" + if credentials is None: + credentials = load_credentials() + + credentials.validate(require_v2=True) + client = get_ncm_client(credentials) + v2_client = client.v2 if hasattr(client, 'v2') else client + + transport = os.environ.get("MCP_TRANSPORT", "streamable-http").lower() + port = int(os.environ.get("NCM_MONITORING_PORT", "3002")) + + if transport == "sse": + mcp = FastMCP("ncm-monitoring", host="0.0.0.0", port=port) + else: + mcp = FastMCP("ncm-monitoring", host="0.0.0.0", port=port) + + register_all(mcp, v2_client) # type: ignore[arg-type] + logger.info("Server configured", extra={"transport": transport, "port": port}) + return mcp + + +def main() -> None: + """Entry point for ncm-monitoring server.""" + try: + server = create_server() + transport = cast(str, os.environ.get("MCP_TRANSPORT", "streamable-http")).lower() + logger.info("Starting ncm-monitoring server") + server.run(transport=transport) # type: ignore[arg-type] + except ValueError as exc: + logger.error("Failed to start server", extra={"error": str(exc)}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ncm_mcp_servers/ncm_monitoring/tools/__init__.py b/ncm_mcp_servers/ncm_monitoring/tools/__init__.py new file mode 100644 index 0000000..5c87ed1 --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/tools/__init__.py @@ -0,0 +1,20 @@ +"""NCM Monitoring tools registration.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from ncm_mcp_servers.shared.ncm import NcmClientv2 + +from ncm_mcp_servers.ncm_monitoring.tools import ( + net_devices, + alerts, + speed_tests, +) + + +def register_all(mcp: "FastMCP", client: "NcmClientv2") -> None: + """Registers all monitoring tools with the MCP server.""" + net_devices.register(mcp, client) + alerts.register(mcp, client) + speed_tests.register(mcp, client) diff --git a/ncm_mcp_servers/ncm_monitoring/tools/alerts.py b/ncm_mcp_servers/ncm_monitoring/tools/alerts.py new file mode 100644 index 0000000..883ad9b --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/tools/alerts.py @@ -0,0 +1,119 @@ +"""MCP tools for NCM alerts and logs. + +Consolidates alert/log queries into: +- get_logs: Unified log retrieval (alerts, recent_alerts, router_logs, activity_logs) +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register alert and log tools.""" + + @mcp.tool() + def get_logs( + source: str = None, + router: Optional[int] = None, + alert_type: Optional[str] = None, + actor: Optional[str] = None, + action_type: Optional[str] = None, + created_at__gt: Optional[str] = None, + created_at__lt: Optional[str] = None, + tzoffset_hrs: Optional[int] = 0, + limit: Optional[int] = None, + ) -> dict: + """Retrieve alerts or logs from various sources. + + Args: + source: One of "alerts", "recent_alerts", "router_logs", + or "activity_logs". + - alerts: Router alerts with date/type filtering. + - recent_alerts: Alerts from the last 24 hours. + - router_logs: Device logs for a specific router. + - activity_logs: NCM platform activity logs. + router: Filter by router ID (alerts, recent_alerts, router_logs). + alert_type: Filter by alert type (alerts only). + actor: Filter by actor ID (activity_logs only). + action_type: Filter by action type (activity_logs only). + created_at__gt: Start date filter (ISO 8601). + created_at__lt: End date filter (ISO 8601). + tzoffset_hrs: Timezone offset in hours (recent_alerts only). + limit: Max results to return. + """ + try: + err = validate_required_params(source=source) + if err is not None: + return err + + if source == "alerts": + kwargs = {} + if router is not None: + kwargs["router"] = router + if alert_type is not None: + kwargs["type"] = alert_type + if created_at__gt is not None: + kwargs["created_at__gt"] = created_at__gt + if created_at__lt is not None: + kwargs["created_at__lt"] = created_at__lt + if limit is not None: + kwargs["limit"] = limit + result = client.get_router_alerts(**kwargs) + + elif source == "recent_alerts": + kwargs = {} + if router is not None: + kwargs["router"] = router + result = client.get_router_alerts_last_24hrs( + tzoffset_hrs=tzoffset_hrs, **kwargs + ) + + elif source == "router_logs": + err = validate_required_params(router=router) + if err is not None: + return err + kwargs = {} + if created_at__gt is not None: + kwargs["created_at__gt"] = created_at__gt + if created_at__lt is not None: + kwargs["created_at__lt"] = created_at__lt + if limit is not None: + kwargs["limit"] = limit + result = client.get_router_logs(router, **kwargs) + + elif source == "activity_logs": + kwargs = {} + if actor is not None: + kwargs["actor__id"] = actor + if action_type is not None: + kwargs["action__type"] = action_type + if created_at__gt is not None: + kwargs["created_at__gt"] = created_at__gt + if created_at__lt is not None: + kwargs["created_at__lt"] = created_at__lt + if limit is not None: + kwargs["limit"] = limit + result = client.get_activity_logs(**kwargs) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid source", + "details": ( + "Valid sources: alerts, recent_alerts, " + "router_logs, activity_logs" + ), + }, + "operation": "get_logs", + } + + return handle_ncm_response(result, "get_logs") + except Exception as e: + return handle_exception(e, "get_logs") diff --git a/ncm_mcp_servers/ncm_monitoring/tools/net_devices.py b/ncm_mcp_servers/ncm_monitoring/tools/net_devices.py new file mode 100644 index 0000000..1052e5d --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/tools/net_devices.py @@ -0,0 +1,123 @@ +"""MCP tools for NCM net device monitoring. + +Consolidates net device metrics into: +- get_net_devices: Query/list net devices +- get_net_device_health: Get cellular health scores +- get_net_device_metrics: Unified metrics retrieval (signal, usage, wan, modem) +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register net device monitoring tools.""" + + @mcp.tool() + def get_net_devices( + router: Optional[int] = None, + mode: Optional[str] = None, + connection_state: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve net devices with optional filtering by router, mode, or connection state.""" + try: + kwargs = {} + if router is not None: + kwargs["router"] = router + if mode is not None: + kwargs["mode"] = mode + if connection_state is not None: + kwargs["connection_state"] = connection_state + if limit is not None: + kwargs["limit"] = limit + result = client.get_net_devices(**kwargs) + return handle_ncm_response(result, "get_net_devices") + except Exception as e: + return handle_exception(e, "get_net_devices") + + @mcp.tool() + def get_net_device_health( + net_device: Optional[int] = None, + ) -> dict: + """Retrieve cellular health scores, optionally filtered by net device ID.""" + try: + kwargs = {} + if net_device is not None: + kwargs["net_device"] = net_device + result = client.get_net_device_health(**kwargs) + return handle_ncm_response(result, "get_net_device_health") + except Exception as e: + return handle_exception(e, "get_net_device_health") + + @mcp.tool() + def get_net_device_metrics( + metric_type: str = None, + net_device: Optional[int] = None, + created_at__gt: Optional[str] = None, + created_at__lt: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict: + """Retrieve net device metrics by type. + + Args: + metric_type: One of "signal", "usage", "wan", or "modem". + - signal: Signal strength samples over time. + - usage: Data usage samples over time. + - wan: Latest signal/usage metrics for WAN devices. + - modem: Latest signal/usage metrics for modem devices. + net_device: Filter by net device ID. + created_at__gt: Start date (ISO 8601). For signal/usage only. + created_at__lt: End date (ISO 8601). For signal/usage only. + limit: Max results to return. + """ + try: + err = validate_required_params(metric_type=metric_type) + if err is not None: + return err + + kwargs = {} + if net_device is not None: + kwargs["net_device"] = net_device + if limit is not None: + kwargs["limit"] = limit + + if metric_type == "signal": + if created_at__gt is not None: + kwargs["created_at__gt"] = created_at__gt + if created_at__lt is not None: + kwargs["created_at__lt"] = created_at__lt + result = client.get_net_device_signal_samples(**kwargs) + + elif metric_type == "usage": + if created_at__gt is not None: + kwargs["created_at__gt"] = created_at__gt + if created_at__lt is not None: + kwargs["created_at__lt"] = created_at__lt + result = client.get_net_device_usage_samples(**kwargs) + + elif metric_type == "wan": + result = client.get_net_devices_metrics_for_wan(**kwargs) + + elif metric_type == "modem": + result = client.get_net_devices_metrics_for_mdm(**kwargs) + + else: + return { + "success": False, + "error": { + "code": 400, + "message": "Invalid metric_type", + "details": "Valid types: signal, usage, wan, modem", + }, + "operation": "get_net_device_metrics", + } + + return handle_ncm_response(result, "get_net_device_metrics") + except Exception as e: + return handle_exception(e, "get_net_device_metrics") diff --git a/ncm_mcp_servers/ncm_monitoring/tools/speed_tests.py b/ncm_mcp_servers/ncm_monitoring/tools/speed_tests.py new file mode 100644 index 0000000..55cf900 --- /dev/null +++ b/ncm_mcp_servers/ncm_monitoring/tools/speed_tests.py @@ -0,0 +1,89 @@ +"""MCP tools for NCM speed tests. + +Provides: +- create_speed_test: Run speed tests on net devices or all modems on a router +- get_speed_test: Check speed test status/results +""" + +from typing import Optional + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) + + +def register(mcp, client): + """Register speed test tools.""" + + @mcp.tool() + def create_speed_test( + net_device_ids: Optional[list] = None, + router_id: Optional[int] = None, + account_id: Optional[int] = None, + host: Optional[str] = None, + max_test_concurrency: Optional[int] = None, + port: Optional[int] = None, + size: Optional[int] = None, + test_timeout: Optional[int] = None, + test_type: Optional[str] = None, + time: Optional[int] = None, + ) -> dict: + """Create a speed test for net devices or all modems on a router. + + Provide either net_device_ids (list of specific device IDs) or + router_id (auto-discovers all connected modems on that router). + + test_type can be 'TCP Download', 'TCP Upload', or 'TCP Latency'. + """ + try: + if net_device_ids is None and router_id is None: + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Provide either net_device_ids or router_id", + }, + "operation": "create_speed_test", + } + + kwargs = {} + if account_id is not None: + kwargs["account_id"] = account_id + if host is not None: + kwargs["host"] = host + if max_test_concurrency is not None: + kwargs["max_test_concurrency"] = max_test_concurrency + if port is not None: + kwargs["port"] = port + if size is not None: + kwargs["size"] = size + if test_timeout is not None: + kwargs["test_timeout"] = test_timeout + if test_type is not None: + kwargs["test_type"] = test_type + if time is not None: + kwargs["time"] = time + + if net_device_ids is not None: + result = client.create_speed_test(net_device_ids, **kwargs) + else: + result = client.create_speed_test_mdm(router_id, **kwargs) + + return handle_ncm_response(result, "create_speed_test") + except Exception as e: + return handle_exception(e, "create_speed_test") + + @mcp.tool() + def get_speed_test(speed_test_id: int = None) -> dict: + """Get the status and results of a speed test by its ID.""" + try: + err = validate_required_params(speed_test_id=speed_test_id) + if err is not None: + return err + result = client.get_speed_test(speed_test_id) + return handle_ncm_response(result, "get_speed_test") + except Exception as e: + return handle_exception(e, "get_speed_test") diff --git a/ncm_mcp_servers/pyproject.toml b/ncm_mcp_servers/pyproject.toml new file mode 100644 index 0000000..777fda8 --- /dev/null +++ b/ncm_mcp_servers/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ncm-mcp-servers" +version = "1.0.0" +description = "Suite of focused MCP servers for Cradlepoint NCM API management" +requires-python = ">=3.10" +dependencies = [ + "mcp[cli]==1.28.0", + "requests==2.32.3", + "urllib3==2.2.3", +] + +[project.scripts] +ncm-fleet = "ncm_mcp_servers.ncm_fleet.server:main" +ncm-monitoring = "ncm_mcp_servers.ncm_monitoring.server:main" +ncm-cloud-services = "ncm_mcp_servers.ncm_cloud_services.server:main" +ncm-mcp-servers = "ncm_mcp_servers.__main__:main" + +[tool.setuptools.packages.find] +include = ["ncm_mcp_servers*"] + +[tool.setuptools.package-data] +ncm_mcp_servers = ["*.json"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +check_untyped_defs = true +ignore_missing_imports = true +no_implicit_optional = false + +[[tool.mypy.overrides]] +module = "ncm_mcp_servers.shared.ncm" +ignore_errors = true + +[[tool.mypy.overrides]] +module = [ + "ncm_mcp_servers.ncm_fleet.tools.*", + "ncm_mcp_servers.ncm_monitoring.tools.*", + "ncm_mcp_servers.ncm_cloud_services.tools.*", +] +ignore_errors = true diff --git a/ncm_mcp_servers/shared/__init__.py b/ncm_mcp_servers/shared/__init__.py new file mode 100644 index 0000000..b09b7c2 --- /dev/null +++ b/ncm_mcp_servers/shared/__init__.py @@ -0,0 +1,25 @@ +"""Shared module for NCM MCP Servers. + +Contains common utilities: NCM client initialization, error handling, +and credential loading used by all three MCP server packages. +""" + +from ncm_mcp_servers.shared.error_handler import ( + handle_exception, + handle_ncm_response, + validate_required_params, +) +from ncm_mcp_servers.shared.credentials import ( + ApiCredentials, + load_credentials, +) +from ncm_mcp_servers.shared.client import get_ncm_client + +__all__ = [ + "handle_exception", + "handle_ncm_response", + "validate_required_params", + "ApiCredentials", + "load_credentials", + "get_ncm_client", +] diff --git a/ncm_mcp_servers/shared/client.py b/ncm_mcp_servers/shared/client.py new file mode 100644 index 0000000..effe13c --- /dev/null +++ b/ncm_mcp_servers/shared/client.py @@ -0,0 +1,39 @@ +"""NCM client factory for MCP Servers. + +Provides get_ncm_client() that instantiates the correct NcmClient +based on available credentials. +""" + +from typing import Optional, Union + +from ncm_mcp_servers.shared.ncm import NcmClientv2, NcmClientv3, NcmClientv2v3 +from ncm_mcp_servers.shared.credentials import ApiCredentials, load_credentials + + +def get_ncm_client( + credentials: Optional[ApiCredentials] = None, +) -> Union[NcmClientv2, NcmClientv3, NcmClientv2v3]: + """Instantiates the correct NcmClient based on available credentials. + + Returns: + NcmClientv2 when only v2 keys are present. + NcmClientv3 when only a v3 token is present. + NcmClientv2v3 when both are present. + + Raises: + ValueError: If credentials are missing or incomplete. + """ + if credentials is None: + credentials = load_credentials() + + credentials.validate() + + if credentials.has_v2 and credentials.has_v3: + return NcmClientv2v3( + api_keys=credentials.to_v2_dict(), + api_key=credentials.ncm_api_token, + ) + elif credentials.has_v2: + return NcmClientv2(api_keys=credentials.to_v2_dict()) + else: + return NcmClientv3(api_key=credentials.ncm_api_token) diff --git a/ncm_mcp_servers/shared/credentials.py b/ncm_mcp_servers/shared/credentials.py new file mode 100644 index 0000000..e522485 --- /dev/null +++ b/ncm_mcp_servers/shared/credentials.py @@ -0,0 +1,174 @@ +"""Credential loading for NCM MCP Servers. + +Reads API credentials from a JSON file or environment variables. + +Credential resolution order: +1. JSON file at the path specified by NCM_CREDENTIALS_FILE env var +2. JSON file at the default path: /app/credentials.json (Docker) or ./credentials.json +3. Environment variables (X_CP_API_ID, X_CP_API_KEY, X_ECM_API_ID, X_ECM_API_KEY, NCM_API_TOKEN) +""" + +import json +import os +import sys +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ApiCredentials: + """Holds NCM API credentials read from environment variables.""" + + x_cp_api_id: Optional[str] = None + x_cp_api_key: Optional[str] = None + x_ecm_api_id: Optional[str] = None + x_ecm_api_key: Optional[str] = None + ncm_api_token: Optional[str] = None + + @property + def has_v2(self) -> bool: + """True when all four v2 credentials are present.""" + return all([ + self.x_cp_api_id, + self.x_cp_api_key, + self.x_ecm_api_id, + self.x_ecm_api_key, + ]) + + @property + def has_v3(self) -> bool: + """True when the v3 bearer token is present.""" + return self.ncm_api_token is not None and self.ncm_api_token != "" + + def to_v2_dict(self) -> dict: + """Returns v2 credentials as the dict format expected by NcmClientv2.""" + return { + "X-CP-API-ID": self.x_cp_api_id, + "X-CP-API-KEY": self.x_cp_api_key, + "X-ECM-API-ID": self.x_ecm_api_id, + "X-ECM-API-KEY": self.x_ecm_api_key, + } + + @property + def _v2_fields(self) -> dict: + """Maps env var names to their current values for validation.""" + return { + "X_CP_API_ID": self.x_cp_api_id, + "X_CP_API_KEY": self.x_cp_api_key, + "X_ECM_API_ID": self.x_ecm_api_id, + "X_ECM_API_KEY": self.x_ecm_api_key, + } + + def validate(self, require_v2=False, require_v3=False) -> None: + """Validates that required credential sets are provided. + + Args: + require_v2: If True, v2 credentials must be present. + require_v3: If True, v3 credentials must be present. + + Raises ValueError with descriptive message when: + - No credentials of any type are provided + - v2 credentials are partially provided (1-3 of 4 keys) + - Required credentials are missing + """ + v2_fields = self._v2_fields + v2_present = {k: v for k, v in v2_fields.items() if v} + v2_missing = {k for k, v in v2_fields.items() if not v} + + # Check for partial v2 credentials (some but not all) + if 0 < len(v2_present) < 4: + missing_names = ", ".join(sorted(v2_missing)) + raise ValueError( + f"Incomplete v2 API credentials. Missing: {missing_names}" + ) + + if require_v2 and not self.has_v2: + raise ValueError( + "v2 API credentials required. Provide: " + "X_CP_API_ID, X_CP_API_KEY, X_ECM_API_ID, X_ECM_API_KEY" + ) + + if require_v3 and not self.has_v3: + raise ValueError( + "v3 API token required. Provide: NCM_API_TOKEN" + ) + + if not self.has_v2 and not self.has_v3: + raise ValueError( + "No API credentials found. Provide v2 credentials " + "(X_CP_API_ID, X_CP_API_KEY, X_ECM_API_ID, X_ECM_API_KEY) " + "or a v3 token (NCM_API_TOKEN), or both." + ) + + +# Default paths to look for a credentials JSON file +DEFAULT_CREDENTIALS_PATHS = [ + "/app/credentials.json", # Docker container default + "./credentials.json", # Local development +] + + +def _load_credentials_from_file() -> Optional[ApiCredentials]: + """Attempt to load credentials from a JSON file. + + Checks NCM_CREDENTIALS_FILE env var first, then default paths. + Returns None if no file is found. + + Expected JSON format:: + + { + "X_CP_API_ID": "...", + "X_CP_API_KEY": "...", + "X_ECM_API_ID": "...", + "X_ECM_API_KEY": "...", + "NCM_API_TOKEN": "..." + } + """ + paths_to_try = [] + custom_path = os.environ.get("NCM_CREDENTIALS_FILE") + if custom_path: + paths_to_try.append(custom_path) + paths_to_try.extend(DEFAULT_CREDENTIALS_PATHS) + + for path in paths_to_try: + if os.path.isfile(path): + try: + with open(path, "r") as f: + data = json.load(f) + return ApiCredentials( + x_cp_api_id=data.get("X_CP_API_ID") or None, + x_cp_api_key=data.get("X_CP_API_KEY") or None, + x_ecm_api_id=data.get("X_ECM_API_ID") or None, + x_ecm_api_key=data.get("X_ECM_API_KEY") or None, + ncm_api_token=data.get("NCM_API_TOKEN") or None, + ) + except (json.JSONDecodeError, OSError) as exc: + print( + f"Warning: Failed to read credentials from {path}: {exc}", + file=sys.stderr, + ) + return None + + +def _load_credentials_from_env() -> ApiCredentials: + """Reads API credentials from environment variables.""" + return ApiCredentials( + x_cp_api_id=os.environ.get("X_CP_API_ID") or None, + x_cp_api_key=os.environ.get("X_CP_API_KEY") or None, + x_ecm_api_id=os.environ.get("X_ECM_API_ID") or None, + x_ecm_api_key=os.environ.get("X_ECM_API_KEY") or None, + ncm_api_token=os.environ.get("NCM_API_TOKEN") or None, + ) + + +def load_credentials() -> ApiCredentials: + """Load API credentials from file first, then fall back to env vars. + + Resolution order: + 1. JSON file (NCM_CREDENTIALS_FILE env var or default paths) + 2. Environment variables + """ + file_creds = _load_credentials_from_file() + if file_creds is not None: + return file_creds + return _load_credentials_from_env() diff --git a/ncm_mcp_servers/shared/error_handler.py b/ncm_mcp_servers/shared/error_handler.py new file mode 100644 index 0000000..6c23a01 --- /dev/null +++ b/ncm_mcp_servers/shared/error_handler.py @@ -0,0 +1,197 @@ +"""Error handler module for NCM MCP Servers. + +Translates ncm.py responses and exceptions into structured MCP responses. +All output is sanitized to ensure API credentials never appear in responses. +""" + +import os +from typing import Any, Dict, List, Optional + +from requests.exceptions import ConnectionError, Timeout, RequestException + + +# HTTP status code to human-readable message mapping +HTTP_ERROR_MESSAGES = { + 400: "Bad request", + 401: "Unauthorized - invalid or expired API credentials", + 403: "Forbidden - insufficient permissions", + 404: "Resource not found", + 500: "Server error - please retry", +} + +# Environment variable names that hold credential values to sanitize +_CREDENTIAL_ENV_VARS = ( + "X_CP_API_ID", + "X_CP_API_KEY", + "X_ECM_API_ID", + "X_ECM_API_KEY", + "NCM_API_TOKEN", +) + + +def _get_credential_values(): + # type: () -> List[str] + """Returns a list of non-empty credential values from environment.""" + values = [] + for var in _CREDENTIAL_ENV_VARS: + val = os.environ.get(var) + if val: + values.append(val) + return values + + +def _sanitize(text, credential_values=None): + # type: (str, Optional[List[str]]) -> str + """Strip any credential values from the given text.""" + if not isinstance(text, str): + return text + if credential_values is None: + credential_values = _get_credential_values() + for cred in credential_values: + if cred and cred in text: + text = text.replace(cred, "***") + return text + + +def _sanitize_dict(data, credential_values=None): + # type: (Any, Optional[List[str]]) -> Any + """Recursively sanitize all string values in a dict or list.""" + if credential_values is None: + credential_values = _get_credential_values() + if not credential_values: + return data + if isinstance(data, str): + return _sanitize(data, credential_values) + if isinstance(data, dict): + return {k: _sanitize_dict(v, credential_values) for k, v in data.items()} + if isinstance(data, list): + return [_sanitize_dict(item, credential_values) for item in data] + return data + + +def _parse_ncm_error(result): + # type: (str) -> Optional[Dict[str, Any]] + """Parse ncm.py error string format into structured error dict. + + The ncm.py library returns error strings in the format: + "ERROR: {status_code}: {details}" + + Returns a dict with code, message, details if the string matches, + or None if it does not. + """ + if not isinstance(result, str) or not result.startswith("ERROR:"): + return None + parts = result.split(":", 2) + if len(parts) < 2: + return None + try: + code = int(parts[1].strip()) + except (ValueError, IndexError): + return None + details = parts[2].strip() if len(parts) > 2 else "" + message = HTTP_ERROR_MESSAGES.get(code, "Unknown error") + return {"code": code, "message": message, "details": details} + + +def handle_ncm_response(result, operation): + # type: (Any, str) -> dict + """Process ncm.py return values into structured responses. + + - If *result* is an error string ("ERROR: {code}: {details}"), + parse it into a structured error response. + - If *result* is a list or dict, wrap it in a success response. + - All output is sanitized to strip credential values. + + Returns: + dict with ``success``, ``data``/``error``, and ``operation`` keys. + """ + credential_values = _get_credential_values() + + # Check for ncm.py error string + parsed = _parse_ncm_error(result) + if parsed is not None: + parsed["details"] = _sanitize(parsed["details"], credential_values) + return { + "success": False, + "error": parsed, + "operation": operation, + } + + # Successful result — sanitize and wrap + sanitized_data = _sanitize_dict(result, credential_values) + return { + "success": True, + "data": sanitized_data, + "operation": operation, + } + + +def handle_exception(exc, operation): + # type: (Exception, str) -> dict + """Handle exceptions raised during NCM API calls. + + - ConnectionError / Timeout / RequestException -> connectivity error. + - Any other Exception -> generic error without exposing internals. + """ + credential_values = _get_credential_values() + + if isinstance(exc, (ConnectionError, Timeout)): + return { + "success": False, + "error": { + "code": 0, + "message": "Connectivity error - unable to reach NCM API", + "details": _sanitize(str(exc), credential_values), + }, + "operation": operation, + } + + if isinstance(exc, RequestException): + return { + "success": False, + "error": { + "code": 0, + "message": "Connectivity error - unable to reach NCM API", + "details": _sanitize(str(exc), credential_values), + }, + "operation": operation, + } + + # Broad Exception — do not expose internals + return { + "success": False, + "error": { + "code": 0, + "message": "An unexpected error occurred", + "details": "", + }, + "operation": operation, + } + + +def validate_required_params(**params): + # type: (...) -> Optional[dict] + """Check for missing required parameters. + + Pass each required parameter as a keyword argument. If any value is + None, a structured error listing the missing parameter names is + returned. Returns None when all parameters are present. + + Example:: + + err = validate_required_params(router_id=router_id, name=name) + if err is not None: + return err + """ + missing = [name for name, value in params.items() if value is None] + if not missing: + return None + return { + "success": False, + "error": { + "code": 400, + "message": "Missing required parameters", + "details": "Missing: {}".format(", ".join(sorted(missing))), + }, + "operation": "validate_params", + } diff --git a/ncm_mcp_servers/shared/logging.py b/ncm_mcp_servers/shared/logging.py new file mode 100644 index 0000000..7034bc9 --- /dev/null +++ b/ncm_mcp_servers/shared/logging.py @@ -0,0 +1,85 @@ +"""Structured logging configuration for NCM MCP Servers. + +Provides JSON-formatted log output for containerized deployments +and human-readable output for local development. + +Usage: + from ncm_mcp_servers.shared.logging import get_logger + logger = get_logger("ncm_fleet") + logger.info("Server started", extra={"port": 3001}) +""" + +import json +import logging +import os +import sys +from datetime import datetime, timezone +from typing import Optional + + +class JsonFormatter(logging.Formatter): + """Formats log records as single-line JSON for container environments.""" + + def format(self, record: logging.LogRecord) -> str: + log_entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info and record.exc_info[0] is not None: + log_entry["exception"] = self.formatException(record.exc_info) + # Include any extra fields passed via extra={} + for key in record.__dict__: + if key not in logging.LogRecord( + "", 0, "", 0, "", (), None + ).__dict__ and key not in ("message", "msg"): + log_entry[key] = record.__dict__[key] + return json.dumps(log_entry, default=str) + + +class HumanFormatter(logging.Formatter): + """Human-readable formatter for local development.""" + + def __init__(self): + super().__init__( + fmt="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + +def get_logger( + name: str, + level: Optional[str] = None, +) -> logging.Logger: + """Get a configured logger for an NCM MCP server component. + + Args: + name: Logger name (e.g., "ncm_fleet", "ncm_monitoring"). + level: Log level override. Defaults to LOG_LEVEL env var or INFO. + + Returns: + Configured logging.Logger instance. + """ + logger = logging.getLogger(f"ncm_mcp.{name}") + + if logger.handlers: + return logger + + log_level = level or os.environ.get("LOG_LEVEL", "INFO").upper() + logger.setLevel(getattr(logging, log_level, logging.INFO)) + + handler = logging.StreamHandler(sys.stdout) + + log_format = os.environ.get("LOG_FORMAT", "auto").lower() + if log_format == "json" or ( + log_format == "auto" and not sys.stdout.isatty() + ): + handler.setFormatter(JsonFormatter()) + else: + handler.setFormatter(HumanFormatter()) + + logger.addHandler(handler) + logger.propagate = False + + return logger diff --git a/ncm_mcp_servers/shared/ncm.py b/ncm_mcp_servers/shared/ncm.py new file mode 100644 index 0000000..13908c4 --- /dev/null +++ b/ncm_mcp_servers/shared/ncm.py @@ -0,0 +1,5394 @@ +""" +Ericsson Enterprise Wireless Solutions - Cradlepoint NCM API module +Maintained by: Alex Terrell, Jon Gaudu + +Overview: + This module provides easy access to the Cradlepoint NCM API with support + for both v2 and v3 APIs. It includes a singleton pattern for simple usage + and module-level function access for convenience. + +Requirements: + Cradlepoint NCM API Keys are required to make API calls. + - For v2 API: X-CP-API-ID, X-CP-API-KEY, X-ECM-API-ID, X-ECM-API-KEY + - For v3 API: Bearer token + +Usage Options: + + 1. Zero-Configuration Usage (Recommended): + import ncm + + # Set these environment variables once: + # export X_CP_API_ID="b89a24a3" + # export X_CP_API_KEY="4b1d77fe271241b1cfafab993ef0891d" + # export X_ECM_API_ID="c71b3e68-33f5-4e69-9853-14989700f204" + # export X_ECM_API_KEY="f1ca6cd41f326c00e23322795c063068274caa30" + # export NCM_API_TOKEN="your-bearer-token" # For v3 API + + # Then just use it - no setup required! + accounts = ncm.get_accounts() + devices = ncm.get_devices() + routers = ncm.get_routers() + + 2. Explicit Configuration (Alternative): + import ncm + + # Option A: Set up API keys explicitly + api_keys = { + 'X-CP-API-ID': 'b89a24a3', + 'X-CP-API-KEY': '4b1d77fe271241b1cfafab993ef0891d', + 'X-ECM-API-ID': 'c71b3e68-33f5-4e69-9853-14989700f204', + 'X-ECM-API-KEY': 'f1ca6cd41f326c00e23322795c063068274caa30' + } + ncm.set_api_keys(api_keys) + + # Option B: Manual environment variable loading + ncm.set_api_keys() # Manually loads from environment + + 3. Traditional Class Instantiation: + import ncm + api_keys = {...} # Same as above + client = ncm.NcmClient(api_keys=api_keys) + accounts = client.get_accounts() + + 4. Mixed v2/v3 API Usage: + import ncm + api_keys = { + 'X-CP-API-ID': 'b89a24a3', + 'X-CP-API-KEY': '4b1d77fe271241b1cfafab993ef0891d', + 'X-ECM-API-ID': 'c71b3e68-33f5-4e69-9853-14989700f204', + 'X-ECM-API-KEY': 'f1ca6cd41f326c00e23322795c063068274caa30', + 'token': 'your-v3-bearer-token' # For v3 API + } + client = ncm.NcmClient(api_keys=api_keys) + # Methods will automatically route to the appropriate API version + + 5. Backward Compatibility (Legacy Scripts): + from ncm import ncm # Old import pattern still works! + + api_keys = { + 'X-ECM-API-ID': os.environ.get("X_ECM_API_ID"), + 'X-ECM-API-KEY': os.environ.get("X_ECM_API_KEY"), + 'X-CP-API-ID': os.environ.get("X_CP_API_ID"), + 'X-CP-API-KEY': os.environ.get("X_CP_API_KEY"), + 'Authorization': f'Bearer {os.environ.get("TOKEN")}' + } + + # All existing patterns work unchanged: + ncm_client = ncm.NcmClientv3(api_key=token, log_events=True) + ncm_client.set_api_keys(api_keys) # Instance method still works + + # New convenience pattern also available: + ncm.set_api_keys(api_keys) # Module-level method + routers = ncm.get_routers() # Direct method access + +Features: + - Zero-configuration usage with automatic environment variable loading + - Singleton pattern for easy module-level access + - Automatic API version routing (v3 prioritized over v2) + - Module-level function access (ncm.method_name()) + - Automatic initialization on import if environment variables are set + - Full backward compatibility with existing scripts + - Support for both import patterns: "import ncm" and "from ncm import ncm" + - Optimized pagination (default limit 500 vs API default 20) + - Support for limit='all' to get all records without paging + - Automatic chunking of "__in" filters beyond 100 item limit + +Full documentation of the Cradlepoint NCM API is available at: +https://developer.cradlepoint.com + +""" + +from requests import Session +from requests.adapters import HTTPAdapter +from http import HTTPStatus +from urllib3.util.retry import Retry +from datetime import datetime, timedelta +import sys +import os +import json +import uuid +from typing import Union, Optional, Dict, Any, Tuple + + +def __is_json(test_json): + """ + Checks if a string is a valid json object + """ + try: + json.loads(test_json) + except ValueError: + return False + return True + + +class BaseNcmClient: + def __init__(self, + log_events=True, + logger=None, + retries=5, + retry_backoff_factor=2, + retry_on=None, + base_url=None): + """ + Constructor. Sets up and opens request session. + :param retries: number of retries on failure. Optional. + :param retry_backoff_factor: backoff time multiplier for retries. + Optional. + :param retry_on: types of errors on which automatic retry will occur. + Optional. + :param base_url: # base url for calls. Configurable for testing. + Optional. + """ + if retry_on is None: + retry_on = [ + HTTPStatus.REQUEST_TIMEOUT, + HTTPStatus.GATEWAY_TIMEOUT, + HTTPStatus.SERVICE_UNAVAILABLE + ] + self.log_events = log_events + self.logger = logger + self.session = Session() + self.adapter = HTTPAdapter( + max_retries=Retry(total=retries, + backoff_factor=retry_backoff_factor, + status_forcelist=retry_on, + redirect=3 + ) + ) + self.base_url = base_url + self.session.mount(self.base_url, self.adapter) + + def log(self, level, message): + """ + Logs messages if self.logEvents is True. + """ + if self.log_events: + if self.logger: + log_level = getattr(self.logger, level) + log_level(message) + else: + print(f"{level}: {message}", file=sys.stderr) + + def _return_handler(self, status_code, returntext, obj_type): + """ + Prints returned HTTP request information if self.logEvents is True. + """ + if str(status_code) == '200': + return f'{obj_type} operation successful.' + elif str(status_code) == '201': + self.log('info', '{0} created Successfully'.format(str(obj_type))) + return returntext + elif str(status_code) == '202': + self.log('info', '{0} accepted Successfully'.format(str(obj_type))) + return returntext + elif str(status_code) == '204': + self.log('info', '{0} deleted Successfully'.format(str(obj_type))) + return returntext + elif str(status_code) == '400': + self.log('error', 'Bad Request') + return f'ERROR: {status_code}: {returntext}' + elif str(status_code) == '401': + self.log('error', 'Unauthorized Access') + return f'ERROR: {status_code}: {returntext}' + elif str(status_code) == '404': + self.log('error', 'Resource Not Found\n') + return f'ERROR: {status_code}: {returntext}' + elif str(status_code) == '500': + self.log('error', 'HTTP 500 - Server Error\n') + return f'ERROR: {status_code}: {returntext}' + else: + self.log('info', f'HTTP Status Code: {status_code} - {returntext}\n') + + +class NcmClientv2(BaseNcmClient): + def __init__(self, + api_keys=None, + log_events=True, + logger=None, + retries=5, + retry_backoff_factor=2, + retry_on=None, + base_url=None): + self.v2 = self # for backwards compatibility + base_url = base_url or os.environ.get("CP_BASE_URL", "https://www.cradlepointecm.com/api/v2") + super().__init__(log_events=log_events, logger=logger, retries=retries, retry_backoff_factor=retry_backoff_factor, retry_on=retry_on, base_url=base_url) + if api_keys: + if self.__validate_api_keys(api_keys): + self.session.headers.update(api_keys) + self.session.headers.update({ + 'Content-Type': 'application/json' + }) + + def __validate_api_keys(self, api_keys): + """ + Checks NCM API Keys are a dictionary containing all necessary keys + :param api_keys: Dictionary of API credentials. Optional. + :type api_keys: dict + :return: True if valid + """ + if not isinstance(api_keys, dict): + raise TypeError("API Keys must be passed as a dictionary") + + for key in ('X-CP-API-ID', 'X-CP-API-KEY', 'X-ECM-API-ID', 'X-ECM-API-KEY'): + if not api_keys.get(key): + raise KeyError(f"{key} missing. Please ensure all API Keys are present.") + + return True + + def __get_json(self, get_url, call_type, params=None): + """ + Returns full paginated results, and handles chunking "__in" params + in groups of 100. + """ + results = [] + __in_keys = 0 + if params['limit'] == 'all': + params['limit'] = 1000000 + limit = int(params['limit']) + + if params is not None: + # Ensures that order_by is passed as a comma separated string + if 'order_by' in params.keys(): + if type(params['order_by']) is list: + params['order_by'] = ','.join( + str(x) for x in params['order_by']) + elif type(params['order_by']) is not list and type( + params['order_by']) is not str: + raise TypeError( + "Invalid 'order_by' parameter. " + "Must be 'list' or 'str'.") + + for key, val in params.items(): + # Handles multiple filters using __in fields. + if '__in' in key: + __in_keys += 1 + # Cradlepoint limit of 100 values. + # If more than 100 values, break into chunks + chunks = self.__chunk_param(val) + # For each chunk, get the full results list and + # filter by __in parameter + for chunk in chunks: + # Handles a list of int or list of str + chunk_str = ','.join(map(str, chunk)) + params.update({key: chunk_str}) + url = get_url + if params is not None: + from urllib.parse import urlencode + query_string = urlencode(params) + separator = '&' if '?' in url else '?' + url = f'{url}{separator}{query_string}' + while url and (len(results) < limit): + ncm = self.session.get(url) + if not (200 <= ncm.status_code < 300): + break + self._return_handler(ncm.status_code, + ncm.json()['data'], + call_type) + url = ncm.json()['meta']['next'] + for d in ncm.json()['data']: + results.append(d) + + if __in_keys == 0: + url = get_url + if params is not None: + from urllib.parse import urlencode + query_string = urlencode(params) + separator = '&' if '?' in url else '?' + url = f'{url}{separator}{query_string}' + while url and (len(results) < limit): + ncm = self.session.get(url) + if not (200 <= ncm.status_code < 300): + break + self._return_handler(ncm.status_code, ncm.json()['data'], + call_type) + url = ncm.json()['meta']['next'] + for d in ncm.json()['data']: + results.append(d) + return results + + def __parse_kwargs(self, kwargs, allowed_params): + """ + Increases default return limit to 500, + and checks for invalid parameters + """ + params = {k: v for (k, v) in kwargs.items() if k in allowed_params} + if 'limit' not in params: + params.update({'limit': '500'}) + + bad_params = {k: v for (k, v) in kwargs.items() if + k not in allowed_params} + if len(bad_params) > 0: + raise ValueError("Invalid parameters: {}".format(bad_params)) + + self.__validate_api_keys(dict(self.session.headers)) + + return params + + def __chunk_param(self, param): + """ + Chunks parameters into groups of 100 per Cradlepoint limit. + Iterate through chunks with a for loop. + """ + n = 100 + + if type(param) is str: + param_list = param.split(",") + elif type(param) is list: + param_list = param + else: + raise TypeError("Invalid param format. Must be str or list.") + + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(param_list), n): + yield param_list[i:i + n] + + def set_api_keys(self, api_keys): + """ + Sets NCM API Keys for session. + :param api_keys: Dictionary of API credentials. Optional. + :type api_keys: dict + """ + if self.__validate_api_keys(api_keys): + self.session.headers.update(api_keys) + return + + def get_accounts(self, **kwargs): + """ + Returns accounts with details. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of accounts based on API Key. + """ + call_type = 'Accounts' + get_url = '{0}/accounts/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'fields', 'id', 'id__in', + 'name', 'name__in', 'expand', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_account_by_id(self, account_id): + """ + This method returns a single account for a given account id. + :param account_id: ID of account to return + :return: + """ + return self.get_accounts(id=account_id)[0] + + def get_account_by_name(self, account_name): + """ + This method returns a single account for a given account name. + :param account_name: Name of account to return + :return: + """ + + return self.get_accounts(name=account_name)[0] + + def create_subaccount_by_parent_id(self, parent_account_id, + subaccount_name): + """ + This operation creates a new subaccount. + :param parent_account_id: ID of parent account. + :param subaccount_name: Name for new subaccount. + :return: + """ + call_type = 'Subaccount' + post_url = '{0}/accounts/'.format(self.base_url) + + post_data = { + 'account': '/api/v1/accounts/{}/'.format(str(parent_account_id)), + 'name': str(subaccount_name) + } + + ncm = self.session.post(post_url, data=json.dumps(post_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def create_subaccount_by_parent_name(self, parent_account_name, + subaccount_name): + """ + This operation creates a new subaccount. + :param parent_account_name: Name of parent account. + :param subaccount_name: Name for new subaccount. + :return: + """ + return self.create_subaccount_by_parent_id(self.get_account_by_name( + parent_account_name)['id'], subaccount_name) + + def rename_subaccount_by_id(self, subaccount_id, new_subaccount_name): + """ + This operation renames a subaccount + :param subaccount_id: ID of subaccount to rename + :param new_subaccount_name: New name for subaccount + :return: + """ + call_type = 'Subaccount' + put_url = '{0}/accounts/{1}/'.format(self.base_url, str(subaccount_id)) + + put_data = { + "name": str(new_subaccount_name) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def rename_subaccount_by_name(self, subaccount_name, new_subaccount_name): + """ + This operation renames a subaccount + :param subaccount_name: Name of subaccount to rename + :param new_subaccount_name: New name for subaccount + :return: + """ + return self.rename_subaccount_by_id(self.get_account_by_name( + subaccount_name)['id'], new_subaccount_name) + + def delete_subaccount_by_id(self, subaccount_id): + """ + This operation deletes a subaccount + :param subaccount_id: ID of subaccount to delete + :return: + """ + call_type = 'Subaccount' + post_url = '{0}/accounts/{1}'.format(self.base_url, subaccount_id) + + ncm = self.session.delete(post_url) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def delete_subaccount_by_name(self, subaccount_name): + """ + This operation deletes a subaccount + :param subaccount_name: Name of subaccount to delete + :return: + """ + return self.delete_subaccount_by_id(self.get_account_by_name( + subaccount_name)['id']) + + def get_activity_logs(self, **kwargs): + """ + This method returns NCM activity log information. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Activity Logs' + get_url = '{0}/activity_logs/'.format(self.base_url) + + allowed_params = ['account', 'created_at__exact', 'created_at__lt', + 'created_at__lte', 'created_at__gt', + 'created_at__gte', 'action__timestamp__exact', + 'action__timestamp__lt', + 'action__timestamp__lte', 'action__timestamp__gt', + 'action__timestamp__gte', 'actor__id', + 'object__id', 'action__id__exact', 'actor__type', + 'action__type', 'object__type', 'order_by', + 'limit'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_alerts(self, **kwargs): + """ + This method gives alert information with associated id. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Alerts' + get_url = '{0}/alerts/'.format(self.base_url) + + allowed_params = ['account', 'created_at', 'created_at_timeuuid', + 'detected_at', 'friendly_info', 'info', + 'router', 'type', 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_configuration_managers(self, **kwargs): + """ + A configuration manager is an abstract resource for controlling and + monitoring config sync on a single device. + Each device has its own corresponding configuration manager. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Configuration Managers' + get_url = '{0}/configuration_managers/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'fields', 'id', 'id__in', + 'router', 'router__in', 'synched', + 'suspended', 'expand', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_configuration_manager_id(self, router_id, **kwargs): + """ + A configuration manager is an abstract resource for controlling and + monitoring config sync on a single device. + Each device has its own corresponding configuration manager. + :param router_id: Router ID to query + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Configuration Managers' + get_url = '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, router_id) + + allowed_params = ['account', 'account__in', 'id', 'id__in', 'router', + 'router__in', 'synched', + 'suspended', 'expand', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params)[0]['id'] + + def update_configuration_managers(self, config_man_id, config_man_json): + """ + This method updates an configuration_managers for associated id. + :param config_man_id: ID of the Configuration Manager to modify + :param config_man_json: JSON of the "configuration" field of the + configuration manager + :return: + """ + call_type = 'Configuration Manager' + put_url = '{0}/configuration_managers/{1}/'.format(self.base_url, + config_man_id) + + ncm = self.session.put(put_url, json=config_man_json) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def patch_configuration_managers(self, router_id, config_man_json): + """ + This method patches an configuration_managers for associated id. + :param router_id: ID of router to update + :param config_man_json: JSON of the "configuration" field of the + configuration manager + :return: + """ + call_type = 'Configuration Manager' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID for router + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + payload = config_man_json + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def put_configuration_managers(self, router_id, configman_json): + """ + This method overwrites the configuration for a router with id. + :param router_id: ID of router to update + :param configman_json: JSON of the "configuration" field of the + configuration manager + :return: + """ + call_type = 'Configuration Manager' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID for router + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + configman_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + payload = configman_json + + ncm = self.session.put( + '{0}/configuration_managers/{1}/?fields=configuration'.format( + self.base_url, str(configman_id)), + json=payload) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def patch_group_configuration(self, group_id, config_json): + """ + This method patches an configuration_managers for associated id. + :param group_id: ID of group to update + :param config_json: JSON of the "configuration" field of the + configuration manager + :return: + """ + call_type = 'Configuration Manager' + + payload = config_json + + ncm = self.session.patch( + '{0}/groups/{1}/'.format(self.base_url, str(group_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def put_group_configuration(self, group_id, config_json): + """ + This method puts a group configuration for associated group id. + :param group_id: ID of group to update + :param config_json: JSON of the "configuration" field of the + group config + :return: + """ + call_type = 'Configuration Manager' + + payload = config_json + + ncm = self.session.put( + '{0}/groups/{1}/'.format(self.base_url, str(group_id)), + data=json.dumps(payload)) # put group config with new values + result = self.__return_handler(ncm.status_code, ncm.text, call_type) + return result + + def patch_group(self, group_id, **kwargs): + """ + This method patches (updates) specific fields of a group. + Only the provided fields will be updated. + + :param group_id: ID of the group to update + :param kwargs: Group fields to update. Supported fields: + - account: url - Account that a group belongs to + - configuration: json - Configuration for the group + - name: string - Name of the group + - product: url - Product type for the group + - target_firmware: url - Firmware version for the group + :return: API response + """ + call_type = 'Group Update' + + # Define the allowed writable fields based on the API documentation + allowed_fields = { + 'account', 'configuration', 'name', 'product', 'target_firmware' + } + + # Filter kwargs to only include allowed fields + payload = {} + for field, value in kwargs.items(): + if field in allowed_fields: + payload[field] = value + else: + if self.log_events: + print(f"Warning: Field '{field}' is not a supported writable field and will be ignored.") + + if not payload: + raise ValueError("No valid writable fields provided. Supported fields: {}".format(', '.join(sorted(allowed_fields)))) + + ncm = self.session.patch( + '{0}/groups/{1}/'.format(self.base_url, str(group_id)), + data=json.dumps(payload)) + + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def copy_router_configuration(self, src_router_id, dst_router_id): + """ + Copies the Configuration Manager config of one router to another. + This function will not copy any passwords as they are encrypted. + :param src_router_id: Router ID to copy from + :param dst_router_id: Router ID to copy to + :return: Should return HTTP Status Code 202 if successful + """ + call_type = 'Configuration Manager' + """Get source router existing configuration""" + src_config = self.get_configuration_managers(router=src_router_id, + fields='configuration')[0] + + """Strip passwords which aren't stored in plain text""" + src_config = json.dumps(src_config).replace(', "wpapsk": "*"','').replace('"wpapsk": "*"', '').replace(', "password": "*"', '').replace('"password": "*"', '') + + """Get destination router Configuration Manager ID""" + dst_config_man_id = \ + self.get_configuration_managers(router=dst_router_id)[0]['id'] + + put_url = '{0}/configuration_managers/{1}/'.format(self.base_url, + dst_config_man_id) + + ncm = self.session.patch(put_url, data=src_config) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def resume_updates_for_router(self, router_id): + """ + This method will resume updates for a router in Sync Suspended state. + :param router_id: ID of router to update + :return: + """ + call_type = 'Configuration Manager' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID for router + response = json.loads(response.content.decode("utf-8")) + configman_id = response['data'][0]['id'] + payload = {"suspended": False} + + ncm = self.session.put( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(configman_id)), + json=payload) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def get_device_app_bindings(self, **kwargs): + """ + This method gives device app binding information for all device + app bindings associated with the account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Device App Bindings' + get_url = '{0}/device_app_bindings/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'group', 'group__in', + 'app_version', 'app_version__in', + 'id', 'id__in', 'state', 'state__in', 'expand', + 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_device_app_states(self, **kwargs): + """ + This method gives device app state information for all device + app states associated with the account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Device App States' + get_url = '{0}/device_app_states/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'router', 'router__in', + 'app_version', 'app_version__in', + 'id', 'id__in', 'state', 'state__in', 'expand', + 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_device_app_versions(self, **kwargs): + """ + This method gives device app version information for all device + app versions associated with the account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Device App Versions' + get_url = '{0}/device_app_versions/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'app', 'app__in', 'id', + 'id__in', 'state', 'state__in', + 'expand', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_device_apps(self, **kwargs): + """ + This method gives device app information for all device apps + associated with the account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Device Apps' + get_url = '{0}/device_apps/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'name', 'name__in', 'id', + 'id__in', 'uuid', 'uuid__in', + 'expand', 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_failovers(self, **kwargs): + """ + This method returns a list of Failover Events for + a device, group, or account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Failovers' + get_url = '{0}/failovers/'.format(self.base_url) + + allowed_params = ['account_id', 'group_id', 'router_id', 'started_at', + 'ended_at', 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_firmwares(self, **kwargs): + """ + This operation gives the list of device firmwares. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Firmwares' + get_url = '{0}/firmwares/'.format(self.base_url) + + allowed_params = ['id', 'id__in', 'version', 'version__in', 'limit', + 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_firmware_for_product_id_by_version(self, product_id, + firmware_name): + """ + This operation returns firmwares for a given model ID and version name. + :param product_id: The ID of the product (e.g. 46) + :param firmware_name: The Firmware Version (e.g. 7.2.0) + :return: + """ + for f in self.get_firmwares(version=firmware_name): + if f['product'] == '{0}/products/{1}/'.format(self.base_url, + str(product_id)): + return f + raise ValueError("Invalid Firmware Version") + + def get_firmware_for_product_name_by_version(self, product_name, + firmware_name): + """ + This operation returns firmwares for a given model and version name. + :param product_name: The Name of the product (e.g. IBR200) + :param firmware_name: The Firmware Version (e.g. 7.2.0) + :return: + """ + product_id = self.get_product_by_name(product_name)['id'] + return self.get_firmware_for_product_id_by_version(product_id, + firmware_name) + + def get_groups(self, **kwargs): + """ + This method gives a groups list. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Groups' + get_url = '{0}/groups/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'id', 'id__in', 'name', + 'name__in', 'expand', 'fields', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_group_by_id(self, group_id): + """ + This method returns a single group. + :param group_id: The ID of the group. + :return: + """ + return self.get_groups(id=group_id)[0] + + def get_group_by_name(self, group_name): + """ + This method returns a single group. + :param group_name: The Name of the group. + :return: + """ + return self.get_groups(name=group_name)[0] + + def create_group_by_parent_id(self, parent_account_id, group_name, + product_name, firmware_version): + """ + This operation creates a new group. + :param parent_account_id: ID of parent account + :param group_name: Name for new group + :param product_name: Product model (e.g. IBR200) + :param firmware_version: Firmware version for group (e.g. 7.2.0) + :return: + Example: n.create_group_by_parent_id('123456', 'My New Group', + 'IBR200', '7.2.0') + """ + + call_type = 'Group' + post_url = '{0}/groups/'.format(self.base_url) + + firmware = self.get_firmware_for_product_name_by_version( + product_name, firmware_version) + + post_data = { + 'account': '/api/v1/accounts/{}/'.format(str(parent_account_id)), + 'name': str(group_name), + 'product': str( + self.get_product_by_name(product_name)['resource_url']), + 'target_firmware': str(firmware['resource_url']) + } + + ncm = self.session.post(post_url, data=json.dumps(post_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def create_group_by_parent_name(self, parent_account_name, group_name, + product_name, firmware_version): + """ + This operation creates a new group. + :param parent_account_name: Name of parent account + :param group_name: Name for new group + :param product_name: Product model (e.g. IBR200) + :param firmware_version: Firmware version for group (e.g. 7.2.0) + :return: + Example: n.create_group_by_parent_name('Parent Account', + 'My New Group', 'IBR200', '7.2.0') + """ + + return self.create_group_by_parent_id( + self.get_account_by_name(parent_account_name)['id'], group_name, + product_name, firmware_version) + + def rename_group_by_id(self, group_id, new_group_name): + """ + This operation renames a group by specifying ID. + :param group_id: ID of the group to rename. + :param new_group_name: New name for the group. + :return: + """ + call_type = 'Group' + put_url = '{0}/groups/{1}/'.format(self.base_url, group_id) + + put_data = { + "name": str(new_group_name) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def rename_group_by_name(self, existing_group_name, new_group_name): + """ + This operation renames a group by specifying name. + :param existing_group_name: Name of the group to rename + :param new_group_name: New name for the group. + :return: + """ + return self.rename_group_by_id( + self.get_group_by_name(existing_group_name)['id'], new_group_name) + + def delete_group_by_id(self, group_id): + """ + This operation deletes a group by specifying ID. + :param group_id: ID of the group to delete + :return: + """ + call_type = 'Group' + post_url = '{0}/groups/{1}/'.format(self.base_url, group_id) + + ncm = self.session.delete(post_url) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def delete_group_by_name(self, group_name): + """ + This operation deletes a group by specifying Name. + :param group_name: Name of the group to delete + :return: + """ + return self.delete_group_by_id( + self.get_group_by_name(group_name)['id']) + + def get_historical_locations(self, router_id, **kwargs): + """ + This method returns a list of locations visited by a device. + :param router_id: ID of the router + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Historical Locations' + get_url = '{0}/historical_locations/?router={1}'.format(self.base_url, + router_id) + + allowed_params = ['created_at__gt', 'created_at_timeuuid__gt', + 'created_at__lte', 'fields', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_historical_locations_for_date(self, router_id, date, + tzoffset_hrs=0, limit='all', + **kwargs): + """ + This method provides a history of device alerts. + To receive device alerts, you must enable them through the NCM UI: + Alerts -> Settings. The info section of the alert is firmware dependent + and may change between firmware releases. + :param router_id: ID of the router + :param date: Date to filter logs. Must be in format "YYYY-mm-dd" + :type date: str + :param tzoffset_hrs: Offset from UTC for local timezone + :type tzoffset_hrs: int + :param limit: Number of records to return. + Specifying "all" returns all records. Default all. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + + d = datetime.strptime(date, '%Y-%m-%d') + timedelta(hours=tzoffset_hrs) + start = d.strftime("%Y-%m-%dT%H:%M:%S") + end = (d + timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + + call_type = 'Historical Locations' + get_url = '{0}/historical_locations/?router={1}'.format(self.base_url, + router_id) + + allowed_params = ['created_at__gt', 'created_at_timeuuid__gt', + 'created_at__lte', 'fields', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + params.update({'created_at__lte': end, + 'created_at__gt': start, + 'limit': limit}) + + return self.__get_json(get_url, call_type, params=params) + + def get_locations(self, **kwargs): + """ + This method gives a list of locations. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Locations' + get_url = '{0}/locations/'.format(self.base_url) + + allowed_params = ['id', 'id__in', 'router', 'router__in', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def create_location(self, account_id, latitude, longitude, router_id): + """ + This method creates a location and applies it to a router. + :param account_id: Account which owns the object + :param latitude: A device's relative position north or south + on the Earth's surface, in degrees from the Equator + :param longitude: A device's relative position east or west + on the Earth's surface, in degrees from the prime meridian + :param router_id: Device that the location is associated with + :return: + """ + + call_type = 'Locations' + post_url = '{0}/locations/'.format(self.base_url) + + post_data = { + 'account': + 'https://www.cradlepointecm.com/api/v2/accounts/{}/'.format( + str(account_id)), + 'accuracy': 0, + 'latitude': latitude, + 'longitude': longitude, + 'method': 'manual', + 'router': 'https://www.cradlepointecm.com/api/v2/routers/{}/' + .format(str(router_id)) + } + + ncm = self.session.post(post_url, data=json.dumps(post_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def delete_location_for_router(self, router_id): + """ + This operation deletes the location for a router by ID. + :param router_id: ID of router for which to remove location. + :return: + """ + call_type = 'Locations' + + locations = self.get_locations(router=router_id) + if locations: + location_id = locations[0]['id'] + + post_url = '{0}/locations/{1}/'.format(self.base_url, location_id) + + ncm = self.session.delete(post_url) + result = self._return_handler(ncm.status_code, ncm.text, + call_type) + return result + else: + return "NO LOCATION FOUND" + + def get_net_device_health(self, **kwargs): + """ + This operation gets cellular heath scores, by device. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Net Device Health' + get_url = '{0}/net_device_health/'.format(self.base_url) + + allowed_params = ['net_device'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_net_device_metrics(self, **kwargs): + """ + This endpoint is supplied to allow easy access to the latest signal and + usage data reported by an account's net_devices without querying the + historical raw sample tables, which are not optimized for a query + spanning many net_devices at once. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Net Device Metrics' + get_url = '{0}/net_device_metrics/'.format(self.base_url) + + allowed_params = ['net_device', 'net_device__in', 'update_ts__lt', + 'update_ts__gt', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_net_devices_metrics_for_wan(self, **kwargs): + """ + This endpoint is supplied to allow easy access to the latest signal and + usage data reported by an account's net_devices without querying the + historical raw sample tables, which are not optimized for a query + spanning many net_devices at once. Returns data only for + WAN interfaces. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + ids = [] + for net_device in self.get_net_devices(mode='wan'): + ids.append(net_device['id']) + idstring = ','.join(str(x) for x in ids) + return self.get_net_device_metrics(net_device__in=idstring, **kwargs) + + def get_net_devices_metrics_for_mdm(self, **kwargs): + """ + This endpoint is supplied to allow easy access to the latest signal and + usage data reported by an account's net_devices without querying the + historical raw sample tables, which are not optimized for a query + spanning many net_devices at once. Returns data only for + modem interfaces. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + ids = [] + for net_device in self.get_net_devices(is_asset=True): + ids.append(net_device['id']) + idstring = ','.join(str(x) for x in ids) + return self.get_net_device_metrics(net_device__in=idstring, **kwargs) + + def get_net_device_signal_samples(self, **kwargs): + """ + This endpoint is supplied to allow easy access to the latest signal and + usage data reported by an account's net_devices without querying the + historical raw sample tables, which are not optimized for a query + spanning many net_devices at once. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Get Net Device Signal Samples' + get_url = '{0}/net_device_signal_samples/'.format(self.base_url) + + allowed_params = ['net_device', 'net_device__in', 'created_at', + 'created_at__lt', 'created_at__gt', + 'created_at_timeuuid', 'created_at_timeuuid__in', + 'created_at_timeuuid__gt', + 'created_at_timeuuid__gte', + 'created_at_timeuuid__lt', + 'created_at_timeuuid__lte', + 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_net_device_usage_samples(self, **kwargs): + """ + This method provides information about the net device's + overall network traffic. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Net Device Usage Samples' + get_url = '{0}/net_device_usage_samples/'.format(self.base_url) + + allowed_params = ['net_device', 'net_device__in', 'created_at', + 'created_at__lt', 'created_at__gt', + 'created_at_timeuuid', 'created_at_timeuuid__in', + 'created_at_timeuuid__gt', + 'created_at_timeuuid__gte', + 'created_at_timeuuid__lt', + 'created_at_timeuuid__lte', + 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_net_devices(self, **kwargs): + """ + This method gives a list of net devices. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Net Devices' + get_url = '{0}/net_devices/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'connection_state', + 'connection_state__in', 'fields', 'id', 'id__in', + 'is_asset', 'ipv4_address', 'ipv4_address__in', + 'mode', 'mode__in', 'router', 'router__in', + 'updated_at__gt', 'updated_at__lt', + 'expand', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_net_devices_for_router(self, router_id, **kwargs): + """ + This method gives a list of net devices for a given router. + :param router_id: ID of the router + :return: + """ + return self.get_net_devices(router=router_id, **kwargs) + + def get_net_devices_for_router_by_mode(self, router_id, mode, **kwargs): + """ + This method gives a list of net devices for a given router, + filtered by mode (lan/wan). + :param router_id: ID of router + :param mode: lan/wan + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + return self.get_net_devices(router=router_id, mode=mode, **kwargs) + + def get_products(self, **kwargs): + """ + This method gives a list of product information. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Products' + get_url = '{0}/products/'.format(self.base_url) + + allowed_params = ['id', 'id__in', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_product_by_id(self, product_id): + """ + This method returns a single product by ID. + :param product_id: ID of product (e.g. 46) + :return: + """ + return self.get_products(id=product_id)[0] + + def get_product_by_name(self, product_name): + """ + This method returns a single product for a given model name. + :param product_name: Name of product (e.g. IBR200) + :return: + """ + for p in self.get_products(): + if p['name'] == product_name: + return p + raise ValueError("Invalid Product Name") + + def reboot_device(self, router_id): + """ + This operation reboots a device. + :param router_id: ID of router to reboot + :return: + """ + call_type = 'Reboot Device' + post_url = '{0}/reboot_activity/'.format(self.base_url) + + post_data = { + 'router': '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + } + + ncm = self.session.post(post_url, data=json.dumps(post_data)) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def reboot_group(self, group_id): + """ + This operation reboots all routers in a group. + :param group_id: ID of group to reboot + :return: + """ + call_type = 'Reboot Group' + post_url = '{0}/reboot_activity/'.format(self.base_url) + + post_data = { + 'group': '{0}/groups/{1}/'.format(self.base_url, str(group_id)) + } + + ncm = self.session.post(post_url, data=json.dumps(post_data)) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def get_router_alerts(self, **kwargs): + """ + This method provides a history of device alerts. To receive device + alerts, you must enable them through the ECM UI: Alerts -> Settings. + The info section of the alert is firmware dependent and + may change between firmware releases. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Router Alerts' + get_url = '{0}/router_alerts/'.format(self.base_url) + + allowed_params = ['router', 'router__in', 'created_at', + 'created_at__lt', 'created_at__gt', + 'created_at_timeuuid', 'created_at_timeuuid__in', + 'created_at_timeuuid__gt', + 'created_at_timeuuid__gte', + 'created_at_timeuuid__lt', + 'created_at_timeuuid__lte', + 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_router_alerts_last_24hrs(self, tzoffset_hrs=0, **kwargs): + """ + This method provides a history of device alerts. + To receive device alerts, you must enable them through the NCM UI: + Alerts -> Settings. The info section of the alert is firmware dependent + and may change between firmware releases. + :param tzoffset_hrs: Offset from UTC for local timezone + :type tzoffset_hrs: int + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + d = datetime.utcnow() + timedelta(hours=tzoffset_hrs) + end = d.strftime("%Y-%m-%dT%H:%M:%S") + start = (d - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + + call_type = 'Router Alerts' + get_url = '{0}/router_alerts/'.format(self.base_url) + + allowed_params = ['router', 'router__in'] + params = self.__parse_kwargs(kwargs, allowed_params) + + params.update({'created_at__lt': end, + 'created_at__gt': start, + 'order_by': 'created_at_timeuuid', + 'limit': '500'}) + + return self.__get_json(get_url, call_type, params=params) + + def get_router_alerts_for_date(self, date, tzoffset_hrs=0, **kwargs): + """ + This method provides a history of device alerts. + To receive device alerts, you must enable them through the NCM UI: + Alerts -> Settings. The info section of the alert is firmware dependent + and may change between firmware releases. + :param date: Date to filter logs. Must be in format "YYYY-mm-dd" + :type date: str + :param tzoffset_hrs: Offset from UTC for local timezone + :type tzoffset_hrs: int + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + + d = datetime.strptime(date, '%Y-%m-%d') + timedelta(hours=tzoffset_hrs) + start = d.strftime("%Y-%m-%dT%H:%M:%S") + end = (d + timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + + call_type = 'Router Alerts' + get_url = '{0}/router_alerts/'.format(self.base_url) + + allowed_params = ['router', 'router__in'] + params = self.__parse_kwargs(kwargs, allowed_params) + + params.update({'created_at__lt': end, + 'created_at__gt': start, + 'order_by': 'created_at_timeuuid', + 'limit': '500'}) + + return self.__get_json(get_url, call_type, params=params) + + def get_router_logs(self, router_id, **kwargs): + """ + This method provides a history of device events. + To receive device logs you must enable them on the Group settings form. + Enabling device logs can significantly increase the ECM network traffic + from the device to the server depending on how quickly the device is + generating events. + :param router_id: ID of router from which to grab logs. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Router Logs' + get_url = '{0}/router_logs/?router={1}'.format(self.base_url, + router_id) + + allowed_params = ['created_at', 'created_at__lt', 'created_at__gt', + 'created_at_timeuuid', + 'created_at_timeuuid__in', 'created_at_timeuuid__gt', + 'created_at_timeuuid__gte', + 'created_at_timeuuid__lt', + 'created_at_timeuuid__lte', 'order_by', 'limit', + 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_router_logs_last_24hrs(self, router_id, tzoffset_hrs=0): + """ + This method provides a history of device events. + To receive device logs you must enable them on the Group settings form. + Enabling device logs can significantly increase the ECM network traffic + from the device to the server depending on how quickly the device is + generating events. + :param router_id: ID of router from which to grab logs. + :param tzoffset_hrs: Offset from UTC for local timezone + :type tzoffset_hrs: int + :return: + """ + d = datetime.utcnow() + timedelta(hours=tzoffset_hrs) + end = d.strftime("%Y-%m-%dT%H:%M:%S") + start = (d - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + + call_type = 'Router Logs' + get_url = '{0}/router_logs/?router={1}'.format(self.base_url, + router_id) + + params = {'created_at__lt': end, 'created_at__gt': start, + 'order_by': 'created_at_timeuuid', 'limit': '500'} + + return self.__get_json(get_url, call_type, params=params) + + def get_router_logs_for_date(self, router_id, date, tzoffset_hrs=0): + """ + This method provides a history of device events. + To receive device logs you must enable them on the Group settings form. + Enabling device logs can significantly increase the ECM network traffic + from the device to the server depending on how quickly the device is + generating events. + :param router_id: ID of router from which to grab logs. + :param date: Date to filter logs. Must be in format "YYYY-mm-dd" + :type date: str + :param tzoffset_hrs: Offset from UTC for local timezone + :type tzoffset_hrs: int + :return: + """ + + d = datetime.strptime(date, '%Y-%m-%d') + timedelta(hours=tzoffset_hrs) + start = d.strftime("%Y-%m-%dT%H:%M:%S") + end = (d + timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + + call_type = 'Router Logs' + get_url = '{0}/router_logs/?router={1}'.format(self.base_url, + router_id) + + params = {'created_at__lt': end, 'created_at__gt': start, + 'order_by': 'created_at_timeuuid', 'limit': '500'} + + return self.__get_json(get_url, call_type, params=params) + + def get_router_state_samples(self, **kwargs): + """ + This method provides information about the connection state of the + device with the NCM server. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Router State Samples' + get_url = '{0}/router_state_samples/'.format(self.base_url) + + allowed_params = ['router', 'router__in', 'created_at', + 'created_at__lt', 'created_at__gt', + 'created_at_timeuuid', 'created_at_timeuuid__in', + 'created_at_timeuuid__gt', + 'created_at_timeuuid__gte', + 'created_at_timeuuid__lt', + 'created_at_timeuuid__lte', + 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_router_stream_usage_samples(self, **kwargs): + """ + This method provides information about the connection state of the + device with the NCM server. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Router Stream Usage Samples' + get_url = '{0}/router_stream_usage_samples/'.format(self.base_url) + + allowed_params = ['router', 'router__in', 'created_at', + 'created_at__lt', 'created_at__gt', + 'created_at_timeuuid', 'created_at_timeuuid__in', + 'created_at_timeuuid__gt', + 'created_at_timeuuid__gte', + 'created_at_timeuuid__lt', + 'created_at_timeuuid__lte', + 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_routers(self, **kwargs): + """ + This method gives device information with associated id. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + call_type = 'Routers' + get_url = '{0}/routers/'.format(self.base_url) + + allowed_params = ['account', 'account__in', 'device_type', + 'device_type__in', 'fields', 'group', 'group__in', + 'id', 'id__in', 'ipv4_address', 'ipv4_address__in', + 'mac', 'mac__in', 'name', 'name__in', + 'reboot_required', 'reboot_required__in', + 'serial_number', 'serial_number__in', 'state', 'state__in', + 'state_updated_at__lt', 'state_updated_at__gt', + 'updated_at__lt', 'updated_at__gt', 'expand', + 'order_by', 'limit', 'offset'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def get_router_by_id(self, router_id, **kwargs): + """ + This method gives device information for a given router ID. + :param router_id: ID of router + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + return self.get_routers(id=router_id, **kwargs)[0] + + def get_router_by_name(self, router_name, **kwargs): + """ + This method gives device information for a given router name. + :param router_name: Name of router + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + return self.get_routers(name=router_name, **kwargs)[0] + + def get_routers_for_account(self, account_id, **kwargs): + """ + This method gives a groups list filtered by account. + :param account_id: Account ID to filter + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + return self.get_routers(account=account_id, **kwargs) + + def get_routers_for_group(self, group_id, **kwargs): + """ + This method gives a groups list filtered by group. + :param group_id: Group ID to filter + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: + """ + return self.get_routers(group=group_id, **kwargs) + + def rename_router_by_id(self, router_id, new_router_name): + """ + This operation renames a router by ID. + :param router_id: ID of router to rename + :param new_router_name: New name for router + :return: + """ + call_type = 'Router' + put_url = '{0}/routers/{1}/'.format(self.base_url, router_id) + + put_data = { + 'name': str(new_router_name) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def rename_router_by_name(self, existing_router_name, new_router_name): + """ + This operation renames a router by name. + :param existing_router_name: Name of router to rename + :param new_router_name: New name for router + :return: + """ + return self.rename_router_by_id( + self.get_router_by_name(existing_router_name)['id'], new_router_name) + + def assign_router_to_group(self, router_id, group_id): + """ + This operation assigns a router to a group. + :param router_id: ID of router to move. + :param group_id: ID of destination group. + :return: + """ + call_type = "Router" + + put_url = '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + + put_data = { + "group": 'https://www.cradlepointecm.com/api/v2/groups/{}/'.format( + group_id) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def remove_router_from_group(self, router_id=None, router_name=None): + """ + This operation removes a router from its group. + Either the ID or the name must be specified. + :param router_id: ID of router to move. + :param router_name: Name of router to move + :return: + """ + call_type = "Router" + if not router_id and not router_name: + return "ERROR: Either Router ID or Router Name must be specified." + if not router_id: + router_id = self.get_router_by_name(router_name)['id'] + + put_url = '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + + put_data = { + "group": None + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + if ncm.status_code == 201 or ncm.status_code == 202: + self.log('info', 'Router Modified Successfully') + return None + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def assign_router_to_account(self, router_id, account_id): + """ + This operation assigns a router to an account. + :param router_id: ID of router to move. + :param account_id: ID of destination account. + :return: + """ + call_type = "Routers" + + put_url = '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + + put_data = { + "account": + 'https://www.cradlepointecm.com/api/v2/accounts/{}/'.format( + account_id) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def delete_router_by_id(self, router_id): + """ + This operation deletes a router by ID. + :param router_id: ID of router to delete. + :return: + """ + call_type = 'Router' + post_url = '{0}/routers/{1}/'.format(self.base_url, router_id) + + ncm = self.session.delete(post_url) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + unregister_router_by_id = delete_router_by_id + + def delete_router_by_name(self, router_name): + """ + This operation deletes a router by name. + :param router_name: Name of router to delete + :return: + """ + return self.delete_router_by_id( + self.get_router_by_name(router_name)['id']) + + unregister_router_by_name = delete_router_by_name + + def create_speed_test(self, net_device_ids: list, account_id=None, + host="netperf-west.bufferbloat.net", + max_test_concurrency=5, port=12865, size=None, + test_timeout=10, test_type="TCP Download", time=10): + """ + This method creates a speed test using Netperf. + + Usage Example: + n.create_speed_test([12345]) + + :param account_id: Account in which to create the speed_test record. + :param host: URL of Speedtest Server. + :param max_test_concurrency: Number of maximum simultaneous tests to server (1-50). + :param net_device_ids: List of net_device IDs (up to 10,000 net_device IDs per request). + :param port: TCP port for test. + :param size: Number of bytes to transfer. + :param test_timeout: Test timeout in seconds. + :param test_type: TCP Download, TCP Upload, TCP Latency + :param time: Test time + :return: + """ + call_type = 'Speed Test' + post_url = '{0}/speed_test/'.format(self.base_url) + + if account_id is None: + account_id = self.get_accounts()[0]['id'] + + post_data = { + "account": f"https://www.cradlepointecm.com/api/v2/accounts/{account_id}/", + "config": { + "host": host, + "max_test_concurrency": max_test_concurrency, + "net_device_ids": net_device_ids, + "port": port, + "size": size, + "test_timeout": test_timeout, + "test_type": test_type, + "time": time + } + } + + ncm = self.session.post(post_url, data=json.dumps(post_data)) + if ncm.status_code == 201: + return ncm.json() + else: + return ncm.text + + def create_speed_test_mdm(self, router_id, account_id=None, + host="netperf-west.bufferbloat.net", + max_test_concurrency=5, port=12865, size=None, + test_timeout=10, test_type="TCP Download", time=10): + """ + This method creates a speed test using Netperf for all connected + modems by specifying a router_id. This is helpful when the desired + net_device_id(s) are not known + + Usage Example: + n.create_speed_test_mdm(12345) + + :param account_id: Account in which to create the speed_test record. + :param host: URL of Speedtest Server. + :param max_test_concurrency: Number of maximum simultaneous tests to server (1-50). + :param router_id: Router ID to test. + :param port: TCP port for test. + :param size: Number of bytes to transfer. + :param test_timeout: Test timeout in seconds. + :param test_type: TCP Download, TCP Upload, TCP Latency + :param time: Test time + :return: + """ + + net_devices = self.get_net_devices_for_router(router_id, connection_state='connected', is_asset=True) + net_device_ids = [int(x["id"]) for x in net_devices] + speed_test = self.create_speed_test(net_device_ids=net_device_ids, + account_id=account_id, + host=host, + max_test_concurrency=max_test_concurrency, + port=port, + size=size, + test_timeout=test_timeout, + test_type=test_type, + time=time) + return speed_test + + def get_speed_test(self, speed_test_id, **kwargs): + """ + This method gets the status/results of a created speed test. + + Usage Example: + speed_test = n.create_speed_test([123456]) + n.get_speed_test(speed_test['id']) + + :param speed_test_id: ID of a speed_test record + :return: + """ + call_type = 'Speed Test' + get_url = '{0}/speed_test/{1}/'.format(self.base_url, speed_test_id) + + return self.session.get(get_url).json() + + + def set_lan_ip_address(self, router_id, lan_ip, netmask=None, + network_id=0): + """ + This method sets the Primary LAN IP Address for a given router id. + :param router_id: ID of router to update + :param lan_ip: LAN IP Address. (e.g. 192.168.1.1) + :param netmask: Subnet mask. (e.g. 255.255.255.0) + :param network_id: The ID of the network to update. + Numbering starts from 0. Defaults to Primary LAN. + :return: + """ + call_type = 'LAN IP Address' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + if netmask: + payload = { + "configuration": [ + { + "lan": { + network_id: { + "ip_address": lan_ip, + "netmask": netmask + } + } + }, + [] + ] + } + + else: + payload = { + "configuration": [ + { + "lan": { + network_id: { + "ip_address": lan_ip + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_custom1(self, router_id, text): + """ + This method updates the Custom1 field in NCM for a given router id. + :param router_id: ID of router to update. + :param text: The text to set for the field + :return: + """ + call_type = "NCM Field Update" + + put_url = '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + + put_data = { + "custom1": str(text) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def set_custom2(self, router_id, text): + """ + This method updates the Custom2 field in NCM for a given router id. + :param router_id: ID of router to update. + :param text: The text to set for the field + :return: + """ + call_type = "NCM Field Update" + + put_url = '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + + put_data = { + "custom2": str(text) + } + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def set_admin_password(self, router_id: int, new_password: str): + """ + This method sets the local admin password for a router. + :param router_id: ID of router to update + :param new_password: Cleartext password to assign + :return: + """ + call_type = 'Admin Password' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + payload = { + "configuration": [ + { + "system": { + "users": { + "0": { + "password": new_password + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_router_name(self, router_id: int, new_router_name: str): + """ + This method sets the local admin password for a router. + :param router_id: ID of router to update + :param new_router_name: Name/System ID to set + :return: + """ + call_type = 'Router Name' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + payload = { + "configuration": [ + { + "system": { + "system_id": new_router_name + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_router_description(self, router_id: int, new_router_description: str): + """ + This method sets the local admin password for a router. + :param router_id: ID of router to update + :param new_router_description: Description string to set + :return: + """ + call_type = 'Description' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + payload = { + "configuration": [ + { + "system": { + "desc": new_router_description + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_router_asset_id(self, router_id: int, new_router_asset_id: str): + """ + This method sets the local admin password for a router. + :param router_id: ID of router to update + :param new_router_asset_id: Asset ID string to set + :return: + """ + call_type = 'Asset ID' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + payload = { + "configuration": [ + { + "system": { + "asset_id": new_router_asset_id + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_ethernet_wan_ip(self, router_id: int, new_wan_ip: str, + new_netmask: str = None, new_gateway: str = None): + """ + This method sets the Ethernet WAN IP Address for a given router id. + :param router_id: ID of router to update + :param new_wan_ip: IP Address to assign to Ethernet WAN + :param new_netmask: Network Mask in dotted decimal notation (optional) + :param new_gateway: IP of gateway (optional) + :return: + """ + call_type = 'Etheret WAN IP Address' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + ip_override = { + "ip_address": new_wan_ip + } + + if new_netmask: + ip_override['netmask'] = new_netmask + + if new_gateway: + ip_override['gateway'] = new_gateway + + payload = { + "configuration": [ + { + "wan": { + "rules2": { + "0": { + "ip_override": ip_override + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def add_custom_apn(self, router_id: int, new_carrier: str, new_apn: str): + """ + This method adds a new APN to the Advanced APN configuration + :param router_id: ID of router to update + :param new_carrier: Home Carrier / PLMN + :param new_apn: APN + :return: + """ + call_type = 'Custom APN' + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id,configuration'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + new_apn_id = 0 + try: + if response['data'][0]['configuration'][0]['wan']: + if response['data'][0]['configuration'][0]['wan']['custom_apns']: + new_apn_id = len(response['data'][0]['configuration'][0]['wan']['custom_apns']) + except KeyError: + pass + + payload = { + "configuration": [ + { + "wan": { + "custom_apns": { + new_apn_id: { + "apn": new_apn, + "carrier": new_carrier + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + + def set_ncm_api_keys_by_router(self, router_id=None, router_name=None, x_ecm_api_id: str = None, x_ecm_api_key: str = None, x_cp_api_id: str = None, x_cp_api_key: str = None, bearer_token: str = ''): + """ + This method sets NCM API keys using the router's certificate management configuration + :param router_id: ID of router to update (optional if router_name is provided) + :param router_name: Name of router to update (optional if router_id is provided) + :param x_ecm_id: ECM ID + :param x_ecm_api_key: ECM API Key + :param x_cp_api_id: CP API ID + :param x_cp_api_key: CP API Key + :param bearer_token: Bearer Token + :return: + """ + call_type = 'Set NCM API Keys' + + if not router_id and not router_name: + raise Exception("Either router_id or router_name must be provided") + + if router_name and not router_id: + try: + router_id = self.get_router_by_name(router_name)['id'] + except Exception as e: + raise Exception(f"Router with name '{router_name}' not found: {str(e)}") + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id,configuration'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + + # Check response status + if response.status_code != 200: + raise Exception(f"Failed to get configuration manager: HTTP {response.status_code} - {response.text}") + + response_data = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + + # Check if response has data and is not empty + if 'data' not in response_data: + raise Exception(f"Unexpected API response format. Response: {response_data}") + + if not response_data['data'] or len(response_data['data']) == 0: + raise Exception(f"No configuration manager found for router_id: {router_id}") + + config_man_id = response_data['data'][0][ + 'id'] # get the Configuration Managers ID from response + + x509 = "-----BEGIN CERTIFICATE-----\nMIIB0jCCATugAwIBAgIUIF7Bygk4C0l0ikNv00u98unXZ9kwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMB4XDTI1MDYwNDA5MjYzNloXDTM1\nMDYwMzA5MjYzNlowFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMIGfMA0GCSqGSIb3\nDQEBAQUAA4GNADCBiQKBgQDHWAtI42kixQBU9yZdiTmakxlj1OGfXlYGYDTMr/Q7\neFRZHLxJwIwrfV4UjJSvXkeo9ui1JNXzfQzDwZXdJKEdFM0fBpu9TD/cyetz9lCs\nh5YL1aC0IcH/liZwGt/z2X4snqe3KADHjy8Dl/5ib16vTC/FuRm02Bf8wVJ0c/sr\nhwIDAQABoxswGTAJBgNVHREEAjAAMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQEL\nBQADgYEAB5UavmWqkT7MXnt2/RE2qdtoTw4PfWIo+I2O7FAwJmHISubp3LW1vCn0\nRIsnyscH+BZmQkZOk3AYhLikgSky64HRHK32HXrLr79ku4as0drJzxuVOOKJn1+6\nDiNWTpAhzT55WU3fZ9H6FRvfEls0ZtLia/yiZ60rH01RO0lo2bs=\n-----END CERTIFICATE-----\n" + payload = { + "configuration": [ + { + "certmgmt": { + "certs": { + "00000000-abcd-1234-abcd-123456789000": { + "_id_": "00000000-abcd-1234-abcd-123456789000", + "key": x_ecm_api_id, + "name": "X-ECM-API-ID", + "x509": x509 + }, + "00000001-abcd-1234-abcd-123456789000": { + "_id_": "00000001-abcd-1234-abcd-123456789000", + "key": x_ecm_api_key, + "name": "X-ECM-API-KEY", + "x509": x509 + }, + "00000002-abcd-1234-abcd-123456789000": { + "_id_": "00000002-abcd-1234-abcd-123456789000", + "key": x_cp_api_id, + "name": "X-CP-API-ID", + "x509": x509 + }, + "00000003-abcd-1234-abcd-123456789000": { + "_id_": "00000003-abcd-1234-abcd-123456789000", + "key": x_cp_api_key, + "name": "X-CP-API-KEY", + "x509": x509 + }, + "00000004-abcd-1234-abcd-123456789000": { + "_id_": "00000004-abcd-1234-abcd-123456789000", + "key": bearer_token, + "name": "Bearer Token", + "x509": x509 + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_ncm_api_keys_by_group(self, group_id=None, group_name=None, x_ecm_api_id: str = None, x_ecm_api_key: str = None, x_cp_api_id: str = None, x_cp_api_key: str = None, bearer_token: str = ''): + """ + This method sets NCM API keys using the group's certificate management configuration + :param group_id: ID of group to update (optional if group_name is provided) + :param group_name: Name of group to update (optional if group_id is provided) + :param x_ecm_id: ECM ID + :param x_ecm_api_key: ECM API Key + :param x_cp_api_id: CP API ID + :param x_cp_api_key: CP API Key + :param bearer_token: Bearer Token + :return: + """ + call_type = 'Set NCM API Keys' + + if not group_id and not group_name: + raise Exception("Either group_id or group_name must be provided") + + if group_name and not group_id: + try: + group_id = self.get_group_by_name(group_name)['id'] + except Exception as e: + raise Exception(f"Group with name '{group_name}' not found: {str(e)}") + + x509 = "-----BEGIN CERTIFICATE-----\nMIIB0jCCATugAwIBAgIUIF7Bygk4C0l0ikNv00u98unXZ9kwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMB4XDTI1MDYwNDA5MjYzNloXDTM1\nMDYwMzA5MjYzNlowFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMIGfMA0GCSqGSIb3\nDQEBAQUAA4GNADCBiQKBgQDHWAtI42kixQBU9yZdiTmakxlj1OGfXlYGYDTMr/Q7\neFRZHLxJwIwrfV4UjJSvXkeo9ui1JNXzfQzDwZXdJKEdFM0fBpu9TD/cyetz9lCs\nh5YL1aC0IcH/liZwGt/z2X4snqe3KADHjy8Dl/5ib16vTC/FuRm02Bf8wVJ0c/sr\nhwIDAQABoxswGTAJBgNVHREEAjAAMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQEL\nBQADgYEAB5UavmWqkT7MXnt2/RE2qdtoTw4PfWIo+I2O7FAwJmHISubp3LW1vCn0\nRIsnyscH+BZmQkZOk3AYhLikgSky64HRHK32HXrLr79ku4as0drJzxuVOOKJn1+6\nDiNWTpAhzT55WU3fZ9H6FRvfEls0ZtLia/yiZ60rH01RO0lo2bs=\n-----END CERTIFICATE-----\n" + payload = { + "configuration": [ + { + "certmgmt": { + "certs": { + "00000000-abcd-1234-abcd-123456789000": { + "_id_": "00000000-abcd-1234-abcd-123456789000", + "key": x_ecm_api_id, + "name": "X-ECM-API-ID", + "x509": x509 + }, + "00000001-abcd-1234-abcd-123456789000": { + "_id_": "00000001-abcd-1234-abcd-123456789000", + "key": x_ecm_api_key, + "name": "X-ECM-API-KEY", + "x509": x509 + }, + "00000002-abcd-1234-abcd-123456789000": { + "_id_": "00000002-abcd-1234-abcd-123456789000", + "key": x_cp_api_id, + "name": "X-CP-API-ID", + "x509": x509 + }, + "00000003-abcd-1234-abcd-123456789000": { + "_id_": "00000003-abcd-1234-abcd-123456789000", + "key": x_cp_api_key, + "name": "X-CP-API-KEY", + "x509": x509 + }, + "00000004-abcd-1234-abcd-123456789000": { + "_id_": "00000004-abcd-1234-abcd-123456789000", + "key": bearer_token, + "name": "Bearer Token", + "x509": x509 + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/groups/{1}/'.format(self.base_url, str(group_id)), + data=json.dumps(payload)) # Patch group config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_encrypted_value_by_router(self, router_id=None, router_name=None, name: str = None, key: str = None): + """ + This method sets an encrypted value using the router's certificate management configuration + :param router_id: ID of router to update (optional if router_name is provided) + :param router_name: Name of router to update (optional if router_id is provided) + :param name: Name of the encrypted value + :param key: Key value to encrypt + :return: + """ + call_type = 'Set Encrypted Value' + + if not router_id and not router_name: + raise Exception("Either router_id or router_name must be provided") + + if not name or not key: + raise Exception("Both name and key parameters must be provided") + + if router_name and not router_id: + try: + router_id = self.get_router_by_name(router_name)['id'] + except Exception as e: + raise Exception(f"Router with name '{router_name}' not found: {str(e)}") + + response = self.session.get( + '{0}/configuration_managers/?router.id={1}&fields=id,configuration'.format( + self.base_url, + str(router_id))) # Get Configuration Managers ID + response = json.loads(response.content.decode( + "utf-8")) # Decode the response and make it a dictionary + config_man_id = response['data'][0][ + 'id'] # get the Configuration Managers ID from response + + x509 = "-----BEGIN CERTIFICATE-----\nMIIB0jCCATugAwIBAgIUIF7Bygk4C0l0ikNv00u98unXZ9kwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMB4XDTI1MDYwNDA5MjYzNloXDTM1\nMDYwMzA5MjYzNlowFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMIGfMA0GCSqGSIb3\nDQEBAQUAA4GNADCBiQKBgQDHWAtI42kixQBU9yZdiTmakxlj1OGfXlYGYDTMr/Q7\neFRZHLxJwIwrfV4UjJSvXkeo9ui1JNXzfQzDwZXdJKEdFM0fBpu9TD/cyetz9lCs\nh5YL1aC0IcH/liZwGt/z2X4snqe3KADHjy8Dl/5ib16vTC/FuRm02Bf8wVJ0c/sr\nhwIDAQABoxswGTAJBgNVHREEAjAAMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQEL\nBQADgYEAB5UavmWqkT7MXnt2/RE2qdtoTw4PfWIo+I2O7FAwJmHISubp3LW1vCn0\nRIsnyscH+BZmQkZOk3AYhLikgSky64HRHK32HXrLr79ku4as0drJzxuVOOKJn1+6\nDiNWTpAhzT55WU3fZ9H6FRvfEls0ZtLia/yiZ60rH01RO0lo2bs=\n-----END CERTIFICATE-----\n" + + # Generate a unique ID for the certificate + cert_id = str(uuid.uuid4()) + + payload = { + "configuration": [ + { + "certmgmt": { + "certs": { + cert_id: { + "_id_": cert_id, + "key": key, + "name": name, + "x509": x509 + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/configuration_managers/{1}/'.format(self.base_url, + str(config_man_id)), + data=json.dumps(payload)) # Patch indie config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_encrypted_value_by_group(self, group_id=None, group_name=None, name: str = None, key: str = None): + """ + This method sets an encrypted value using the group's certificate management configuration + :param group_id: ID of group to update (optional if group_name is provided) + :param group_name: Name of group to update (optional if group_id is provided) + :param name: Name of the encrypted value + :param key: Key value to encrypt + :return: + """ + call_type = 'Set Encrypted Value' + + if not group_id and not group_name: + raise Exception("Either group_id or group_name must be provided") + + if not name or not key: + raise Exception("Both name and key parameters must be provided") + + if group_name and not group_id: + try: + group_id = self.get_group_by_name(group_name)['id'] + except Exception as e: + raise Exception(f"Group with name '{group_name}' not found: {str(e)}") + + x509 = "-----BEGIN CERTIFICATE-----\nMIIB0jCCATugAwIBAgIUIF7Bygk4C0l0ikNv00u98unXZ9kwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMB4XDTI1MDYwNDA5MjYzNloXDTM1\nMDYwMzA5MjYzNlowFzEVMBMGA1UEAwwMTmV0Q2xvdWQgQVBJMIGfMA0GCSqGSIb3\nDQEBAQUAA4GNADCBiQKBgQDHWAtI42kixQBU9yZdiTmakxlj1OGfXlYGYDTMr/Q7\neFRZHLxJwIwrfV4UjJSvXkeo9ui1JNXzfQzDwZXdJKEdFM0fBpu9TD/cyetz9lCs\nh5YL1aC0IcH/liZwGt/z2X4snqe3KADHjy8Dl/5ib16vTC/FuRm02Bf8wVJ0c/sr\nhwIDAQABoxswGTAJBgNVHREEAjAAMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQEL\nBQADgYEAB5UavmWqkT7MXnt2/RE2qdtoTw4PfWIo+I2O7FAwJmHISubp3LW1vCn0\nRIsnyscH+BZmQkZOk3AYhLikgSky64HRHK32HXrLr79ku4as0drJzxuVOOKJn1+6\nDiNWTpAhzT55WU3fZ9H6FRvfEls0ZtLia/yiZ60rH01RO0lo2bs=\n-----END CERTIFICATE-----\n" + + # Generate a unique ID for the certificate + cert_id = str(uuid.uuid4()) + + payload = { + "configuration": [ + { + "certmgmt": { + "certs": { + cert_id: { + "_id_": cert_id, + "key": key, + "name": name, + "x509": x509 + } + } + } + }, + [] + ] + } + + ncm = self.session.patch( + '{0}/groups/{1}/'.format(self.base_url, str(group_id)), + data=json.dumps(payload)) # Patch group config with new values + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def set_router_fields(self, router_id: int, name: str = None, description: str = None, asset_id: str = None, custom1: str = None, custom2: str = None): + """ + This method sets multiple fields for a router. + :param router_id: ID of router to update + :param name: Name/System ID to set + :param description: Description string to set + :param asset_id: Asset ID string to set + :param custom1: Custom1 field to set + :param custom2: Custom2 field to set + :return: + """ + call_type = 'Router Fields' + + put_url = '{0}/routers/{1}/'.format(self.base_url, str(router_id)) + + put_data = {} + for k,v in (('name', name), ('description', description), ('asset_id', asset_id), ('custom1', custom1), ('custom2', custom2)): + if v is not None: + put_data[k] = v + + ncm = self.session.put(put_url, data=json.dumps(put_data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def get_router_appdata(self, router_id_or_name: Union[int, str], **kwargs) -> list: + """ + Get appdata from router configuration. + Retrieves the system/sdk/appdata from the router's configuration. + + :param router_id_or_name: ID (int) or name (str) of the router to get appdata from + :type router_id_or_name: Union[int, str] + :param kwargs: Additional parameters for the API call + :return: List of appdata items with _id_, name, and value fields + :rtype: list + """ + try: + # Get router with configuration_manager expanded + if isinstance(router_id_or_name, int): + router = self.get_router_by_id(router_id_or_name, expand='configuration_manager', **kwargs) + else: + # Check for multiple routers with the same name + routers_with_name = self.get_routers(name=router_id_or_name, **kwargs) + if len(routers_with_name) > 1: + print(f"Warning: Found {len(routers_with_name)} routers with name '{router_id_or_name}'. Using the first one (ID: {routers_with_name[0].get('id')})") + print(f"All routers with this name: {[r.get('id') for r in routers_with_name]}") + elif len(routers_with_name) == 0: + raise ValueError(f"No router found with name '{router_id_or_name}'") + + router = self.get_router_by_name(router_id_or_name, expand='configuration_manager', **kwargs) + + # Navigate to system/sdk/appdata through configuration_manager, always use item 0 + config_manager = router.get('configuration_manager', {}) + configuration = config_manager.get('configuration', []) + + if configuration and len(configuration) > 0: + first_config = configuration[0] # Always use item 0 + appdata_dict = (first_config + .get('system', {}) + .get('sdk', {}) + .get('appdata', {})) + + # Convert dict to list of appdata items + if isinstance(appdata_dict, dict): + return list(appdata_dict.values()) + else: + return [] + else: + return [] + + except Exception as e: + print(f"Error getting appdata for router {router_id_or_name}: {e}") + return [] + + def get_router_appdata_value(self, router_id_or_name: Union[int, str], name: str, **kwargs) -> Optional[str]: + """ + Get a specific appdata value by name from router configuration. + Searches the system/sdk/appdata array for an item with the specified name. + + :param router_id_or_name: ID (int) or name (str) of the router to get appdata from + :type router_id_or_name: Union[int, str] + :param name: Name of the appdata item to retrieve + :type name: str + :param kwargs: Additional parameters for the API call + :return: Value of the appdata item, or None if not found + :rtype: Optional[str] + """ + try: + # Get appdata array (this will handle the duplicate name checking) + sdk_data = self.get_router_appdata(router_id_or_name, **kwargs) + + # Search for item with matching name using method chaining + for item in sdk_data: + if item.get('name') == name: + return item.get('value') + + # Return None if not found + return None + + except Exception as e: + print(f"Error getting appdata value '{name}' for router {router_id_or_name}: {e}") + return None + +class NcmClientv3(BaseNcmClient): + """ + This NCM Client class provides functions for interacting with = + the Cradlepoint NCM API. Full documentation of the Cradlepoint API can be + found at: https://developer.cradlepoint.com + """ + + def __init__(self, + api_key=None, + log_events=False, + logger=None, + retries=5, + retry_backoff_factor=2, + retry_on=None, + base_url=None): + """ + Constructor. Sets up and opens request session. + :param api_key: API Bearer token (without the "Bearer" text). + Optional, but must be set before calling functions. + :type api_key: str + :param log_events: if True, HTTP status info will be printed. False by default + :type log_events: bool + :param retries: number of retries on failure. Optional. + :param retry_backoff_factor: backoff time multiplier for retries. + Optional. + :param retry_on: types of errors on which automatic retry will occur. + Optional. + :param base_url: # base url for calls. Configurable for testing. + Optional. + """ + self.v3 = self # For backwards compatibility + base_url = base_url or os.environ.get("CP_BASE_URL_V3", "https://api.cradlepointecm.com/api/v3") + super().__init__(log_events, logger, retries, retry_backoff_factor, retry_on, base_url) + if api_key: + token = {'Authorization': f'Bearer {api_key}'} + self.session.headers.update(token) + self.session.headers.update({ + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json' + }) + + def __get_json(self, get_url, call_type, params=None): + """ + Returns full paginated results + """ + results = [] + + if params is not None and "limit" in params: + limit = params['limit'] + if limit == 0: + limit = 1000000 + if params['limit'] > 50 or params['limit'] == 0: + params['page[size]'] = 50 + else: + params['page[size]'] = params['limit'] + else: + limit = 50 + + url = get_url + if params is not None: + from urllib.parse import urlencode + query_string = urlencode(params) + url = f'{url}?{query_string}' + + while url and (len(results) < limit): + ncm = self.session.get(url) + if not (200 <= ncm.status_code < 300): + return self._return_handler(ncm.status_code, ncm.json(), call_type) + data = ncm.json()['data'] + if isinstance(data, list): + self._return_handler(ncm.status_code, data, call_type) + for d in data: + results.append(d) + else: + results.append(data) + if "links" in ncm.json(): + url = ncm.json()['links']['next'] + else: + url = None + + if params is not None and "filter[fields]" in params.keys(): + data = [] + fields = params['filter[fields]'].split(",") + for result in results: + items = {} + for k, v in result['attributes'].items(): + if k in fields: + items[k] = v + data.append(items) + return data + + return results + + + def __parse_kwargs(self, kwargs, allowed_params): + """ + Checks for invalid parameters and missing API Keys, and handles "filter" fields + """ + if 'search' in kwargs: + return self.__parse_search_kwargs(kwargs, allowed_params) + + bad_params = {k: v for (k, v) in kwargs.items() if + k not in allowed_params if ("search" not in k and "filter" not in k and "sort" not in k)} + if len(bad_params) > 0: + raise ValueError("Invalid parameters: {}".format(bad_params)) + + if 'Authorization' not in self.session.headers: + raise KeyError( + "API key missing. " + "Please set API key before making API calls.") + + params = {} + + for key, val in kwargs.items(): + if "search" in key or "filter" in key or "sort" in key or "limit" in key: + params[key] = val + + elif "__" in key: + split_key = key.split("__") + params[f'filter[{split_key[0]}][{split_key[1]}]'] = val + else: + params[f'filter[{key}]'] = val + + return params + + def __parse_search_kwargs(self, kwargs, allowed_params): + """ + Checks for invalid parameters and missing API Keys, and handles "search" fields + """ + + bad_params = {k: v for (k, v) in kwargs.items() if + k not in allowed_params if ("search" not in k and "filter" not in k and "sort" not in k)} + if len(bad_params) > 0: + raise ValueError("Invalid parameters: {}".format(bad_params)) + + if 'Authorization' not in self.session.headers: + raise KeyError( + "API key missing. " + "Please set API key before making API calls.") + + params = {} + + for key, val in kwargs.items(): + if "filter" in key or "sort" in key or "limit" in key: + params[key] = val + elif "fields" in key: + params[f'filter[{key}]'] = val + else: + if "search" not in key: + params[f'search[{key}]'] = val + + return params + + def __parse_put_kwargs(self, kwargs, allowed_params): + """ + Checks for invalid parameters and missing API Keys, and handles "filter" fields + """ + + bad_params = {k: v for (k, v) in kwargs.items() if + k not in allowed_params if ("search" not in k and "filter" not in k and "sort" not in k)} + if len(bad_params) > 0: + raise ValueError("Invalid parameters: {}".format(bad_params)) + + if 'Authorization' not in self.session.headers: + raise KeyError( + "API key missing. " + "Please set API key before making API calls.") + + return kwargs + + def set_api_key(self, api_key): + """ + Sets NCM API Keys for session. + :param api_key: API Bearer token (without the "Bearer" prefix). + :type api_key: str + """ + if api_key: + token = {'Authorization': f'Bearer {api_key}'} + self.session.headers.update(token) + return + + def get_users(self, **kwargs): + """ + Returns users with details. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of users with details. + """ + call_type = 'Users' + get_url = f'{self.base_url}/beta/users' + + allowed_params = ['email', + 'email__not', + 'first_name', + 'first_name__ne', + 'id', + 'is_active__ne', + 'last_login', + 'last_login__lt', + 'last_login__lte', + 'last_login__gt', + 'last_login__gte', + 'last_login__ne', + 'last_name', + 'last_name__ne', + 'pending_email', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def create_user(self, email, first_name, last_name, **kwargs): + """ + Creates a user. + :param email: Email address + :type email: str + :param first_name: First name + :type first_name: str + :param last_name: Last name + :type last_name: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: User creation result. + """ + call_type = 'User' + post_url = f'{self.base_url}/beta/users' + + allowed_params = ['is_active', + 'last_login', + 'pending_email'] + params = self.__parse_kwargs(kwargs, allowed_params) + params['email'] = email + params['first_name'] = first_name + params['last_name'] = last_name + + """GET TENANT ID""" + t = self.get_subscriptions(limit=1) + + data = { + "data": { + "type": "users", + "attributes": params, + "relationships": { + "tenant": { + "data": [t[0]['relationships']['tenants']['data']] + } + } + } + } + + ncm = self.session.post(post_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def update_user(self, email, **kwargs): + """ + Updates a user's date. + :param email: Email address + :type email: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: User update result. + """ + call_type = 'Users' + + user = self.get_users(email=email)[0] + user.pop('links') + + put_url = f'{self.base_url}/beta/users/{user["id"]}' + + allowed_params = ['first_name', + 'last_name', + 'is_active', + 'user_id', + 'last_login', + 'pending_email'] + params = self.__parse_kwargs(kwargs, allowed_params) + + for k, v in params.items(): + user['attributes'][k] = v + + user = {"data": user} + + ncm = self.session.put(put_url, data=json.dumps(user)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def delete_user(self, email, **kwargs): + """ + Updates a user's date. + :param email: Email address + :type email: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: None unless error. + """ + call_type = 'Users' + + user = self.get_users(email=email)[0] + user.pop('links') + + delete_url = f'{self.base_url}/beta/users/{user["id"]}' + + ncm = self.session.delete(delete_url) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def get_asset_endpoints(self, **kwargs): + """ + Returns assets with details. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of asset endpoints (routers) with details. + """ + call_type = 'Asset Endpoints' + get_url = f'{self.base_url}/asset_endpoints' + + allowed_params = ['id', + 'hardware_series', + 'hardware_series_key', + 'mac_address', + 'serial_number', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_subscriptions(self, **kwargs): + """ + Returns subscriptions with details. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of subscriptions with details. + """ + call_type = 'Subscriptions' + get_url = f'{self.base_url}/subscriptions' + + allowed_params = ['end_time', + 'end_time__lt', + 'end_time__lte', + 'end_time__gt', + 'end_time__gte', + 'end_time__ne', + 'id', + 'name', + 'quantity', + 'start_time', + 'start_time__lt', + 'start_time__lte', + 'start_time__gt', + 'start_time__gte', + 'start_time__ne', + 'tenant', + 'type', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def regrade(self, subscription_id, mac, action="UPGRADE"): + """ + Applies a subscription to an asset. + :param subscription_id: ID of the subscription to apply. See https://developer.cradlepoint.com/ for list of subscriptions. + :param mac: MAC address of the asset to apply the subscription to. Can also be a list. + :param action: Action to take. Default is "UPGRADE". Can also be "DOWNGRADE". + """ + + call_type = 'Subscription' + post_url = f'{self.base_url}/asset_endpoints/regrades' + + payload = { + "atomic:operations": [] + } + mac = mac if isinstance(mac, list) else [mac] + for smac in mac: + data = { + "op": "add", + "data": { + "type": "regrades", + "attributes": { + "action": action, + "subscription_type": subscription_id, + "mac_address": smac.replace(':','') if len(smac) == 17 else smac + } + } + } + payload["atomic:operations"].append(data) + + ncm = self.session.post(post_url, json=payload) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def unlicense_devices(self, mac_addresses): + """ + Unlicenses a device by MAC address using the NCM API v3. + :param mac_addresses: MAC address of the device to unlicense (can be a single MAC address or a list of MAC addresses) + :return: Result of the unlicense operation + """ + call_type = 'Unlicense Device' + post_url = f'{self.base_url}/asset_endpoints/regrades' + + # Convert single MAC address to list for consistent processing + mac_addresses = [mac_addresses] if isinstance(mac_addresses, str) else mac_addresses + + # Create atomic operations for each MAC address + operations = [] + for mac in mac_addresses: + operations.append({ + "op": "add", + "data": { + "type": "regrades", + "attributes": { + "mac_address": mac.replace(':', ''), + "action": "UNLICENSE" + } + } + }) + + payload = { + "atomic:operations": operations + } + + headers = { + 'Content-Type': 'application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"', + 'Accept': 'application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"' + } + + response = self.session.post(post_url, json=payload, headers=headers) + + result = self._return_handler(response.status_code, response.json(), call_type) + return result + + def get_regrades(self, **kwargs): + """ + Returns regrade jobs with details. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of regrades with details. + """ + call_type = 'Subscription' + get_url = f'{self.base_url}/asset_endpoints/regrades' + + allowed_params = ["id", + "action_id", + "mac_address", + "created_at", + "action", + "subcription_type", + "status", + "error_code"] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_networks(self, **kwargs): + """ + Returns information about your private cellular networks. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of PCNs with details. + """ + call_type = 'Private Cellular Networks' + get_url = f'{self.base_url}/beta/private_cellular_networks' + + allowed_params = ['core_ip', + 'created_at', + 'created_at__lt', + 'created_at__lte', + 'created_at__gt', + 'created_at__gte', + 'created_at__ne', + 'ha_enabled', + 'id', + 'mobility_gateways', + 'mobility_gateway_virtual_ip', + 'name', + 'state', + 'status', + 'tac', + 'type', + 'updated_at', + 'updated_at__lt', + 'updated_at__lte', + 'updated_at__gt', + 'updated_at__gte', + 'updated_at__ne', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_network(self, network_id, **kwargs): + """ + Returns information about a private cellular network. + :param network_id: ID of the private_cellular_networks record + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: An individual PCN network with details. + """ + call_type = 'Private Cellular Networks' + get_url = f'{self.base_url}/beta/private_cellular_networks/{network_id}' + + allowed_params = ['name', + 'segw_ip', + 'ha_enabled', + 'mobility_gateway_virtual_ip', + 'state', + 'status', + 'tac', + 'created_at', + 'updated_at', + 'fields'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def update_private_cellular_network(self, id=None, name=None, **kwargs): + """ + Make changes to a private cellular network. + :param id: PCN network ID. Specify either this or name. + :type id: str + :param name: PCN network name + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: PCN update result. + """ + call_type = 'Private Cellular Network' + + if not id and not name: + return "ERROR: no network specified. Must specify either network_id or network_name" + + if id: + net = self.get_private_cellular_networks(id=id)[0] + elif name: + net = self.get_private_cellular_networks(name=name)[0] + + if name: + kwargs['name'] = name + + net.pop('links') + + put_url = f'{self.base_url}/beta/private_cellular_networks/{net["id"]}' + + allowed_params = ['core_ip', + 'ha_enabled', + 'id', + 'mobility_gateways', + 'mobility_gateway_virtual_ip', + 'name', + 'state', + 'status', + 'tac', + 'type'] + params = self.__parse_put_kwargs(kwargs, allowed_params) + + for k, v in params.items(): + net['attributes'][k] = v + + data = {"data": net} + + ncm = self.session.put(put_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def create_private_cellular_network(self, name, core_ip, ha_enabled=False, mobility_gateway_virtual_ip=None, mobility_gateways=None): + """ + Make changes to a private cellular network. + :param name: Name of the networks. + :type name: str + :param core_ip: IP address to reach core network. + :type core_ip: str + :param ha_enabled: High availability (HA) of network. + :type ha_enabled: bool + :param mobility_gateway_virtual_ip: Virtual IP address to reach core when HA is enabled. Nullable. + :type mobility_gateway_virtual_ip: str + :param mobility_gateways: Comma separated list of private_cellular_cores IDs to add as mobility gateways. Nullable. + :type mobility_gateways: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Create PCN result.. + """ + call_type = 'Private Cellular Network' + + post_url = f'{self.base_url}/beta/private_cellular_networks' + + data = { + "data": { + "type": "private_cellular_networks", + "attributes": { + "name": name, + "core_ip": core_ip, + "ha_enabled": ha_enabled, + "mobility_gateway_virtual_ip": mobility_gateway_virtual_ip + } + } + } + + if mobility_gateways: + relationships = { + "mobility_gateways": { + "data": [] + } + } + gateways = mobility_gateways.split(",") + + for gateway in gateways: + relationships['mobility_gateways']['data'].append({"type": "private_cellular_cores", "id": gateway}) + + data['data']['relationships'] = relationships + + ncm = self.session.post(post_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def delete_private_cellular_network(self, id): + """ + Returns information about a private cellular network. + :param id: ID of the private_cellular_networks record + :type id: str + :return: None unless error. + """ + # TODO support deletion by network name + call_type = 'Private Cellular Network' + delete_url = f'{self.base_url}/beta/private_cellular_networks/{id}' + + ncm = self.session.delete(delete_url) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def get_private_cellular_cores(self, **kwargs): + """ + Returns information about a private cellular core. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of Mobility Gateways with details. + """ + call_type = 'Private Cellular Cores' + get_url = f'{self.base_url}/beta/private_cellular_cores' + + allowed_params = ['created_at', + 'id', + 'management_ip', + 'network', + 'router', + 'status', + 'type', + 'updated_at', + 'url', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_core(self, core_id, **kwargs): + """ + Returns information about a private cellular core. + :param core_id: ID of the private_cellular_cores record + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: An individual Mobility Gateway with details. + """ + call_type = 'Private Cellular Core' + get_url = f'{self.base_url}/beta/private_cellular_cores/{core_id}' + + allowed_params = ['created_at', + 'id', + 'management_ip', + 'network', + 'router', + 'status', + 'type', + 'updated_at', + 'url', + 'fields', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_radios(self, **kwargs): + """ + Returns information about a private cellular radio. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of Cellular APs with details. + """ + call_type = 'Private Cellular Radios' + get_url = f'{self.base_url}/beta/private_cellular_radios' + + allowed_params = ['admin_state', + 'antenna_azimuth', + 'antenna_beamwidth', + 'antenna_downtilt', + 'antenna_gain', + 'bandwidth', + 'category', + 'cpi_id', + 'cpi_name', + 'cpi_signature', + 'created_at', + 'description', + 'fccid', + 'height', + 'height_type', + 'id', + 'indoor_deployment', + 'latitude', + 'location', + 'longitude', + 'mac', + 'name', + 'network', + 'serial_number', + 'tdd_mode', + 'tx_power', + 'type', + 'updated_at', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_radio(self, id, **kwargs): + """ + Returns information about a private cellular radio. + :param id: ID of the private_cellular_radios record + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: An individual Cellular AP with details. + """ + call_type = 'Private Cellular Radios' + get_url = f'{self.base_url}/beta/private_cellular_radios/{id}' + + allowed_params = ['admin_state', + 'antenna_azimuth', + 'antenna_beamwidth', + 'antenna_downtilt', + 'antenna_gain', + 'bandwidth', + 'category', + 'cpi_id', + 'cpi_name', + 'cpi_signature', + 'created_at', + 'description', + 'fccid', + 'height', + 'height_type', + 'id', + 'indoor_deployment', + 'latitude', + 'location', + 'longitude', + 'mac', + 'name', + 'network', + 'serial_number', + 'tdd_mode', + 'tx_power', + 'type', + 'updated_at', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def update_private_cellular_radio(self, id=None, name=None, **kwargs): + """ + Updates a Cellular AP's data. + :param id: ID of the private_cellular_radio record. Must specify this or name. + :type id: str + :param name: Name of the Cellular AP. Must specify this or id. + type id: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Update Cellular AP results. + """ + call_type = 'Private Cellular Radio' + + if id: + radio = self.get_private_cellular_radios(id=id)[0] + elif name: + radio = self.get_private_cellular_radios(name=name)[0] + else: + return "ERROR: Must specify either ID or name" + + if name: + kwargs['name'] = name + + put_url = f'{self.base_url}/beta/private_cellular_radios/{radio["id"]}' + + if "network" in kwargs.keys(): + relationships = { + "network": { + "data": { + "type": "private_cellular_networks", + "id": kwargs['network'] + } + } + } + kwargs.pop("network") + + radio['data']['relationships'] = relationships + + if "location" in kwargs.keys(): + location = { + "data": { + "type": "private_cellular_radio_groups", + "id": kwargs['location'] + } + } + kwargs.pop("location") + radio['data']['location'] = location + + allowed_params = ['admin_state', + 'antenna_azimuth', + 'antenna_beamwidth', + 'antenna_downtilt', + 'antenna_gain', + 'bandwidth', + 'category', + 'cpi_id', + 'cpi_name', + 'cpi_signature', + 'created_at', + 'description', + 'fccid', + 'height', + 'height_type', + 'id', + 'indoor_deployment', + 'latitude', + 'location', + 'longitude', + 'mac', + 'name', + 'network', + 'serial_number', + 'tdd_mode', + 'tx_power'] + params = self.__parse_put_kwargs(kwargs, allowed_params) + + for k, v in params.items(): + radio['attributes'][k] = v + + radio = {"data": radio} + + ncm = self.session.put(put_url, data=json.dumps(radio)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def get_private_cellular_radio_groups(self, **kwargs): + """ + Returns information about a private cellular core. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of Cellular AP Groups with details. + """ + call_type = 'Private Cellular Radio Groups' + get_url = f'{self.base_url}/beta/private_cellular_radio_groups' + + allowed_params = ['created_at', + 'created_at__lt', + 'created_at__lte', + 'created_at__gt', + 'created_at__gte', + 'created_at__ne', + 'description', + 'id', + 'name', + 'network', + 'type', + 'updated_at', + 'updated_at__lt', + 'updated_at__lte', + 'updated_at__gt', + 'updated_at__gte', + 'updated_at__ne', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_radio_group(self, group_id, **kwargs): + """ + Returns information about a private cellular core. + :param group_id: ID of the private_cellular_radio_groups record + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: An individual Cellular AP Group with details. + """ + call_type = 'Private Cellular Radio Group' + get_url = f'{self.base_url}/beta/private_cellular_radio_groups/{group_id}' + + allowed_params = ['created_at', + 'description', + 'id', + 'name', + 'network', + 'type', + 'updated_at', + 'fields', + 'limit', + 'sort'] + if "search" not in kwargs.keys(): + params = self.__parse_kwargs(kwargs, allowed_params) + else: + if kwargs['search']: + params = self.__parse_search_kwargs(kwargs, allowed_params) + else: + params = self.__parse_kwargs(kwargs, allowed_params) + + results = self.__get_json(get_url, call_type, params=params) + return results + + def update_private_cellular_radio_group(self, id=None, name=None, **kwargs): + """ + Updates a Radio Group. + :param id: ID of the private_cellular_radio_groups record. Must specify this or name. + :type id: str + :param name: Name of the Radio Group. Must specify this or id. + type name: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Update Cellular AP Group results. + """ + call_type = 'Private Cellular Radio Group' + + if id: + group = self.get_private_cellular_radio_groups(id=id)[0] + elif name: + group = self.get_private_cellular_radio_groups(name=name)[0] + else: + return "ERROR: Must specify either ID or name" + + if name: + kwargs['name'] = name + + put_url = f'{self.base_url}/beta/private_cellular_sims/{group["id"]}' + + if "network" in kwargs.keys(): + relationships = { + "network": { + "data": { + "type": "private_cellular_networks", + "id": kwargs['network'] + } + } + } + kwargs.pop("network") + + group['data']['relationships'] = relationships + + allowed_params = ['name', + 'description'] + params = self.__parse_put_kwargs(kwargs, allowed_params) + + for k, v in params.items(): + group['attributes'][k] = v + + group = {"data": group} + + ncm = self.session.put(put_url, data=json.dumps(group)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def create_private_cellular_radio_group(self, name, description, network=None): + """ + Creates a Radio Group. + :param name: Name of the Radio Group. + type name: str + :param description: Description of the Radio Group. + :type description: str + param network: ID of the private_cellular_network to belong to. Optional. + :type network: str + :return: Create Private Cellular Radio Group results. + """ + call_type = 'Private Cellular Radio Group' + + post_url = f'{self.base_url}/beta/private_cellular_radio_groups' + + group = { + "data": { + "type": "private_cellular_radio_groups", + "attributes": { + "name": name, + "description": description + } + } + } + + if network: + relationships = { + "network": { + "data": { + "type": "private_cellular_networks", + "id": network + } + } + } + + group['data']['relationships'] = relationships + + ncm = self.session.post(post_url, data=json.dumps(group)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def delete_private_cellular_radio_group(self, id): + """ + Deletes a private_cellular_radio_group record. + :param id: ID of the private_cellular_radio_group record + :type id: str + :return: None unless error. + """ + #TODO support deletion by group name + call_type = 'Private Cellular Radio Group' + delete_url = f'{self.base_url}/beta/private_cellular_radio_group/{id}' + + ncm = self.session.delete(delete_url) + result = self._return_handler(ncm.status_code, ncm.text, call_type) + return result + + def get_private_cellular_sims(self, **kwargs): + """ + Returns information about a private cellular core. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: A list of PCN SIMs with details. + """ + call_type = 'Private Cellular SIMs' + get_url = f'{self.base_url}/beta/private_cellular_sims' + + allowed_params = ['created_at', + 'created_at__lt', + 'created_at__lte', + 'created_at__gt', + 'created_at__gte', + 'created_at__ne', + 'iccid', + 'id', + 'imsi', + 'last_contact_at', + 'last_contact_at__lt', + 'last_contact_at__lte', + 'last_contact_at__gt', + 'last_contact_at__gte', + 'last_contact_at__ne', + 'name', + 'network', + 'state', + 'state_updated_at', + 'state_updated_at__lt', + 'state_updated_at__lte', + 'state_updated_at__gt', + 'state_updated_at__gte', + 'state_updated_at__ne', + 'type', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_sim(self, id, **kwargs): + """ + Returns information about a private cellular core. + :param sim_id: ID of the private_cellular_sims record + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: An individual PCN SIM with details. + """ + call_type = 'Private Cellular SIMs' + get_url = f'{self.base_url}/beta/private_cellular_sims/{id}' + + allowed_params = ['created_at', + 'iccid', + 'id', + 'imsi', + 'last_contact_at', + 'name', + 'network', + 'state', + 'state_updated_at', + 'type', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def update_private_cellular_sim(self, id=None, iccid=None, imsi=None, **kwargs): + """ + Updates a SIM's data. + :param id: ID of the private_cellular_sim record. Must specify ID, ICCID, or IMSI. + :type id: str + :param iccid: ICCID. Must specify ID, ICCID, or IMSI. + :type id: str + :param imsi: IMSI. Must specify ID, ICCID, or IMSI. + :type id: str + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Update PCN SIM results. + """ + call_type = 'Private Cellular SIM' + + if id: + sim = self.get_private_cellular_sims(id=id)[0] + elif iccid: + sim = self.get_private_cellular_sims(iccid=iccid)[0] + elif imsi: + sim = self.get_private_cellular_sims(imsi=imsi)[0] + else: + return "ERROR: Must specify either ID, ICCID, or IMSI" + + put_url = f'{self.base_url}/beta/private_cellular_sims/{sim["id"]}' + + if "network" in kwargs.keys(): + relationships = { + "network": { + "data": { + "type": "private_cellular_networks", + "id": kwargs['network'] + } + } + } + kwargs.pop("network") + + sim['data']['relationships'] = relationships + + allowed_params = ['name', + 'state'] + params = self.__parse_put_kwargs(kwargs, allowed_params) + + for k, v in params.items(): + sim['attributes'][k] = v + + sim = {"data": sim} + + ncm = self.session.put(put_url, data=json.dumps(sim)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + return result + + def get_private_cellular_radio_statuses(self, **kwargs): + """ + Returns information about a private cellular core. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Cellular radio status for all cellular radios. + """ + call_type = 'Private Cellular Radio Statuses' + get_url = f'{self.base_url}/beta/private_cellular_radio_statuses' + + allowed_params = ['admin_state', + 'boot_time', + 'cbrs_sas_status', + 'cell', + 'connected_ues', + 'ethernet_status', + 'id', + 'ipsec_status', + 'ipv4_address', + 'last_update_time', + 'online_status', + 'operational_status', + 'operating_tx_power', + 's1_status', + 'time_synchronization', + 'type', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_private_cellular_radio_status(self, status_id, **kwargs): + """ + Returns information about a private cellular core. + :param status_id: ID of the private_cellular_radio_statuses resource + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Cellular radio status for an individual radio. + """ + call_type = 'Private Cellular Radio Status' + get_url = f'{self.base_url}/beta/private_cellular_radio_statuses/{status_id}' + + allowed_params = ['admin_state', + 'boot_time', + 'cbrs_sas_status', + 'cell', + 'connected_ues', + 'ethernet_status', + 'id', + 'ipsec_status', + 'ipv4_address', + 'last_update_time', + 'online_status', + 'operational_status', + 'operating_tx_power', + 's1_status', + 'time_synchronization', + 'type', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + + def get_public_sim_mgmt_assets(self, **kwargs): + """ + Returns information about SIM asset resources in your NetCloud Manager account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: SIM asset resources. + """ + call_type = 'Private Cellular Radio Status' + get_url = f'{self.base_url}/beta/public_sim_mgmt_assets' + + allowed_params = ['assigned_imei', + 'carrier', + 'detected_imei', + 'device_status', + 'iccid', + 'is_licensed' + 'type', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_public_sim_mgmt_rate_plans(self, **kwargs): + """ + Returns information about rate plan resources associated with the SIM assets in your NetCloud Manager account. + :param kwargs: A set of zero or more allowed parameters + in the allowed_params list. + :return: Rate plans for SIM assets. + """ + call_type = 'Private Cellular Radio Status' + get_url = f'{self.base_url}/beta/public_sim_mgmt_assets' + + allowed_params = ['carrier', + 'name', + 'status', + 'fields', + 'limit', + 'sort'] + + params = self.__parse_kwargs(kwargs, allowed_params) + return self.__get_json(get_url, call_type, params=params) + + def get_exchange_sites(self, site_id: str = None, exchange_network_id: str = None, name: str = None, **kwargs) -> list: + """ + Returns information about exchange sites. + + :param site_id: ID of a specific exchange site to retrieve. Optional. + :type site_id: str + :param exchange_network_id: ID of the exchange network to filter sites by. Optional. + :type exchange_network_id: str + :param name: Name of the site to filter by. Optional. + :type name: str + :param kwargs: Optional parameters such as limit, sort, fields. + - limit: Maximum number of sites to return. + - sort: Field to sort the results by. Can be prefixed with '-' for descending order. + Valid sort fields: name, updated_at + - fields: List of fields to include in the response. + Valid fields: name, created_at, updated_at, editable, lan_as_dns, + local_domain, primary_dns, secondary_dns, tags + :return: A list of exchange sites, a single site if site_id is provided, or an error message if no sites are found. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If an invalid parameter or value is provided. + """ + call_type = 'Exchange Sites' + get_url = f'{self.base_url}/beta/exchange_sites' + + allowed_params = { + 'limit': int, + 'sort': str, + 'fields': list + } + + valid_sort_fields = ['name', 'updated_at'] + valid_fields = ['name', 'created_at', 'updated_at', 'editable', 'lan_as_dns', + 'local_domain', 'primary_dns', 'secondary_dns', 'tags'] + + params = {} + + if exchange_network_id: + if not isinstance(exchange_network_id, str): + raise TypeError("exchange_network_id must be a string") + params['filter[exchange_network]'] = exchange_network_id + + if name: + if not isinstance(name, str): + raise TypeError("name must be a string") + params['filter[name]'] = name + + # Type checking and validation for parameters + for key, value in kwargs.items(): + if key in allowed_params: + if not isinstance(value, allowed_params[key]): + raise TypeError(f"{key} must be of type {allowed_params[key].__name__}") + + if key == 'sort': + sort_field = value.lstrip('-') + if sort_field not in valid_sort_fields: + raise ValueError(f"Invalid sort field: {sort_field}") + + elif key == 'fields': + for field in value: + if field not in valid_fields: + raise ValueError(f"Invalid field: {field}") + params[key] = ','.join(value) + else: + params[key] = value + + elif key not in ['search', 'filter']: + raise ValueError(f"Invalid parameter: {key}") + + if site_id: + if not isinstance(site_id, str): + raise TypeError("site_id must be a string") + get_url += f'/{site_id}' + response = self.__get_json(get_url, call_type) + + if response.startswith('ERROR'): + return [f"No site found with site_id: {site_id}"] + return response + + params.update(self.__parse_kwargs(kwargs, allowed_params.keys())) + + response = self.__get_json(get_url, call_type, params=params) + + if not response: + if name: + return [f"No site found with name: {name}"] + if exchange_network_id: + return [f"No sites found for exchange_network_id: {exchange_network_id}"] + + return response + + def create_exchange_site(self, name: str, exchange_network_id: str, router_id: str, **kwargs) -> dict: + """ + Creates an exchange site. + + :param name: Name of the exchange site. + :type name: str + :param exchange_network_id: ID of the exchange network. + :type exchange_network_id: str + :param router_id: ID of the endpoint. + :type router_id: str + :param kwargs: Optional parameters such as primary_dns, secondary_dns, lan_as_dns, local_domain, tags. + - primary_dns: Primary DNS of the exchange site. + - secondary_dns: Secondary DNS of the exchange site. + - lan_as_dns: Whether LAN is used as DNS. Defaults to False. + - local_domain: Local domain of the exchange site. + - tags: List of tags for the exchange site. + :return: The created exchange site data if successful, error message otherwise. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If required parameters are missing or if an invalid parameter or value is provided. + """ + call_type = 'Create Exchange Site' + + # Type checking for required parameters + if not isinstance(name, str): + raise TypeError("name must be a string") + if not isinstance(exchange_network_id, str): + raise TypeError("exchange_network_id must be a string") + if not isinstance(router_id, str): + raise TypeError("router_id must be a string") + + post_url = f'{self.base_url}/beta/exchange_sites' + + allowed_params = { + 'primary_dns': str, + 'secondary_dns': str, + 'lan_as_dns': bool, + 'local_domain': str, + 'tags': list + } + + attributes = { + 'name': name, + 'lan_as_dns': False # Default value + } + + # Process optional parameters + for key, value in kwargs.items(): + if key in allowed_params: + if not isinstance(value, allowed_params[key]): + raise TypeError(f"{key} must be of type {allowed_params[key].__name__}") + if key == 'tags': + if not all(isinstance(tag, str) for tag in value): + raise TypeError("All tags must be strings") + attributes[key] = value + else: + raise ValueError(f"Invalid parameter: {key}") + + # Check if lan_as_dns is True and primary_dns is provided + if attributes.get('lan_as_dns', False) and 'primary_dns' not in attributes: + raise ValueError("primary_dns is required when lan_as_dns is True") + + data = { + "data": { + "type": "exchange_user_managed_sites", + "attributes": attributes, + "relationships": { + "exchange_network": { + "data": { + "id": exchange_network_id, + "type": "exchange_networks" + } + }, + "endpoints": { + "data": [ + { + "id": router_id, + "type": "endpoints" + } + ] + } + } + } + } + + ncm = self.session.post(post_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + if ncm.status_code == 201: + return ncm.json()['data'] + else: + return result + + def update_exchange_site(self, site_id: str = None, name: str = None, **kwargs) -> dict: + """ + Updates an exchange site. + + :param site_id: ID of the exchange site to update. Optional if name is provided. + :type site_id: str, optional + :param name: Name of the exchange site to update or the new name if site_id is provided. + :type name: str, optional + :param kwargs: Optional parameters to update. Can include: + - primary_dns: New primary DNS for the exchange site. + - secondary_dns: New secondary DNS for the exchange site. + - lan_as_dns: Whether LAN should be used as DNS. + - local_domain: New local domain for the exchange site. + - tags: New list of tags for the exchange site. + :return: The updated exchange site data if successful, error message otherwise. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If neither site_id nor name is provided, or if an invalid parameter is provided. + :raises LookupError: If no site is found when searching by id or name. + """ + call_type = 'Update Exchange Site' + + if (site_id is None or site_id == '') and (name is None or name == ''): + raise ValueError("Either site_id or name must be provided and cannot be blank") + + allowed_params = { + 'primary_dns': str, + 'secondary_dns': str, + 'lan_as_dns': bool, + 'local_domain': str, + 'tags': list + } + + # Get current site data + if site_id: + if not isinstance(site_id, str): + raise TypeError("site_id must be a string") + current_site = self.get_exchange_sites(site_id=site_id) + if not current_site: + raise LookupError(f"No site found with id: {site_id}") + current_site = current_site[0] + update_name = name is not None + elif name: + if not isinstance(name, str): + raise TypeError("name must be a string") + current_site = self.get_exchange_sites(name=name) + if not current_site: + raise LookupError(f"No site found with name: {name}") + current_site = current_site[0] + site_id = current_site['id'] + update_name = False + + put_url = f'{self.base_url}/beta/exchange_sites/{site_id}' + + attributes = current_site['attributes'] + exchange_network_id = current_site['relationships']['exchange_network']['data']['id'] + router_id = current_site['relationships']['endpoints']['data'][0]['id'] + + # Update name if site_id was provided and name is different + if update_name and name != attributes['name']: + attributes['name'] = name + + # Update attributes with new values + for key, expected_type in allowed_params.items(): + if key in kwargs: + value = kwargs[key] + if key == 'tags': + if not isinstance(value, list): + raise TypeError("tags must be a list") + if not all(isinstance(tag, str) for tag in value): + raise TypeError("All tags must be strings") + elif not isinstance(value, expected_type): + raise TypeError(f"{key} must be of type {expected_type.__name__}") + attributes[key] = value + + # Check if lan_as_dns is True and primary_dns is provided + if attributes.get('lan_as_dns', False) and 'primary_dns' not in attributes: + raise ValueError("primary_dns is required when lan_as_dns is True") + + data = { + "data": { + "type": "exchange_user_managed_sites", + "id": site_id, + "attributes": attributes, + "relationships": { + "exchange_network": { + "data": { + "type": "exchange_networks", + "id": exchange_network_id + } + }, + "endpoints": { + "data": [{ + "type": "routers", + "id": router_id + }] + } + } + } + } + + ncm = self.session.put(put_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + if ncm.status_code == 200: + return ncm.json()['data'] + else: + return result + + def delete_exchange_site(self, site_id: str = None, site_name: str = None) -> dict: + """ + Deletes an exchange site and its associated resources. + + :param site_id: ID of the exchange site to delete. Optional if site_name is provided. + :type site_id: str, optional + :param site_name: Name of the exchange site to delete. Optional if site_id is provided. + :type site_name: str, optional + :return: The response from the DELETE request. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If neither site_id nor site_name is provided. + """ + call_type = 'Delete Exchange Site' + + if not site_id and not site_name: + raise ValueError("Either site_id or site_name must be provided") + + if site_name and not isinstance(site_name, str): + raise TypeError("site_name must be a string") + if site_id and not isinstance(site_id, str): + raise TypeError("site_id must be a string") + + # Delete associated resources + if site_id: + resource_deletion_results = self.delete_exchange_resource(site_id=site_id) + else: + resource_deletion_results = self.delete_exchange_resource(site_name=site_name) + + # Delete the exchange site + if site_id: + delete_url = f'{self.base_url}/beta/exchange_sites/{site_id}' + else: + site_id = self.get_exchange_sites(name=site_name)[0]["id"] + delete_url = f'{self.base_url}/beta/exchange_sites/{site_id}' + + ncm = self.session.delete(delete_url) + + if ncm.status_code == 204: + site_deletion_result = "deleted" + else: + site_deletion_result = "error" + + return { + "site_deletion_result": site_deletion_result, + "resource_deletion_results": resource_deletion_results + } + + def get_exchange_resources(self, site_id: str = None, exchange_network_id: str = None, resource_id: str = None, site_name: str = None, **kwargs) -> list: + """ + Returns information about exchange resources. + + :param site_id: ID of the exchange site to filter resources by. Optional. + :type site_id: str + :param exchange_network_id: ID of the exchange network to filter resources by. Optional. + :type exchange_network_id: str + :param resource_id: ID of a specific exchange resource to retrieve. Optional. + :type resource_id: str + :param site_name: Name of the exchange site to filter resources by. Optional. + :type site_name: str + :param kwargs: Optional parameters such as name, limit, sort, fields, resource_type. + - name: Name of the resource to filter by. + - limit: Maximum number of resources to return. + - sort: Field to sort the results by. Can be prefixed with '-' for descending order. + - fields: List of fields to include in the response. + - resource_type: Type of resource to filter by (exchange_fqdn_resources, exchange_wildcard_fqdn_resources, or exchange_ipsubnet_resources). + :return: A list of exchange resources or a single resource if resource_id is provided. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If an invalid parameter or value is provided. + :raises LookupError: If no site is found when searching by site_name. + """ + call_type = 'Exchange Resources' + + if resource_id: + if not isinstance(resource_id, str): + raise TypeError("resource_id must be a string") + get_url = f"{self.base_url}/beta/exchange_resources/{resource_id}" + else: + get_url = f"{self.base_url}/beta/exchange_resources" + + params = {} + if site_name: + if not isinstance(site_name, str): + raise TypeError("site_name must be a string") + sites = self.get_exchange_sites(name=site_name) + if not sites: + raise LookupError(f"No site found with name: {site_name}") + site_id = sites[0]['id'] + + if site_id: + if not isinstance(site_id, str): + raise TypeError("site_id must be a string") + params['filter[exchange_site]'] = site_id + elif exchange_network_id: + if not isinstance(exchange_network_id, str): + raise TypeError("exchange_network_id must be a string") + params['filter[exchange_network]'] = exchange_network_id + + allowed_params = { + 'name': str, + 'limit': int, + 'sort': str, + 'fields': list, + 'resource_type': str + } + + valid_sort_fields = ['name', 'created_at', 'updated_at', 'protocols', 'tags', 'domain', 'ip', 'static_prime_ip', 'port_ranges'] + valid_fields = ['name', 'created_at', 'updated_at', 'protocols', 'tags', 'domain', 'ip', 'static_prime_ip', 'port_ranges'] + valid_types = ['exchange_fqdn_resources', 'exchange_wildcard_fqdn_resources', 'exchange_ipsubnet_resources'] + + for key, value in kwargs.items(): + if key in allowed_params: + if not isinstance(value, allowed_params[key]): + raise TypeError(f"{key} must be of type {allowed_params[key].__name__}") + + if key == 'sort': + if value.lstrip('-') not in valid_sort_fields: + raise ValueError(f"Invalid sort field: {value}") + + elif key == 'fields': + for field in value: + if field not in valid_fields: + raise ValueError(f"Invalid field: {field}") + params[key] = ','.join(value) + + elif key == 'resource_type': + if value not in valid_types: + raise ValueError(f"Invalid resource type: {value}. Valid types are: {', '.join(valid_types)}") + params['filter[type]'] = value + + else: + params[key] = value + + elif key not in ['search', 'filter']: + raise ValueError(f"Invalid parameter: {key}") + else: + params[key] = value + + response = self.session.get(get_url, params=params) + + if response.status_code == 200: + data = response.json()['data'] + return data if isinstance(data, list) else [data] + else: + return f"ERROR: {response.status_code}: {response.text}" + + def create_exchange_resource(self, resource_name: str, resource_type: str, site_id: str = None, site_name: str = None, **kwargs) -> dict: + """ + Creates an exchange resource. + + :param resource_name: Name for the new resource. + :type resource_name: str + :param resource_type: Type of resource to create. Must be one of: + 'exchange_fqdn_resources', 'exchange_wildcard_fqdn_resources', or 'exchange_ipsubnet_resources'. + :type resource_type: str + :param site_id: NCX Site ID to add the resource to. Optional if site_name is provided. + :type site_id: str + :param site_name: Name of the NCX Site to add the resource to. Optional if site_id is provided. + :type site_name: str + :param kwargs: Optional parameters for the resource. Can include: + - protocols: List of protocols (e.g., ['TCP'], ['UDP'], ['TCP', 'UDP'], or ['ICMP']). + - tags: List of tags for the resource. + - domain: Domain name for FQDN or wildcard FQDN resources. Required for these types. + For wildcard FQDN, must start with '*.'. + - ip: IP address for IP subnet resources. Required for this type. + - static_prime_ip: Static prime IP for the resource. + - port_ranges: List of port ranges. Each range can be an int, a string (e.g., '80' or '8000-8080'). + Will be converted to a list of dicts with 'lower_limit' and 'upper_limit'. + Not allowed when protocol is ICMP or None. + :return: The created exchange resource data if successful, error message otherwise. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If required parameters are missing, if an invalid resource type is provided, + if an invalid parameter or value is provided, or if port ranges are provided + with ICMP protocol or no protocol. + :raises LookupError: If no site is found when searching by site_name. + """ + call_type = 'Create Exchange Site Resource' + + # Type checking for required parameters + if not isinstance(resource_name, str): + raise TypeError("resource_name must be a string") + if not isinstance(resource_type, str): + raise TypeError("resource_type must be a string") + + # Validate and get site_id + if site_id is None and site_name is None: + raise ValueError("Either site_id or site_name must be provided") + + if site_name: + if not isinstance(site_name, str): + raise TypeError("site_name must be a string") + sites = self.get_exchange_sites(name=site_name) + if not sites: + raise LookupError(f"No site found with name: {site_name}") + site_id = sites[0]['id'] + elif not isinstance(site_id, str): + raise TypeError("site_id must be a string") + + valid_resource_types = ['exchange_fqdn_resources', 'exchange_wildcard_fqdn_resources', 'exchange_ipsubnet_resources', 'exchange_http_resources', 'exchange_https_resources'] + if resource_type not in valid_resource_types: + raise ValueError(f"Invalid resource_type. Must be one of: {', '.join(valid_resource_types)}") + + post_url = f'{self.base_url}/beta/exchange_resources' + + allowed_params = { + 'protocols': (list, type(None)), + 'tags': list, + 'domain': str, + 'ip': str, + 'static_prime_ip': str, + 'port_ranges': (list, type(None)) + } + + attributes = {'name': resource_name} + + # Validate required parameters based on resource_type + if resource_type == 'exchange_ipsubnet_resources': + if 'ip' not in kwargs: + raise ValueError("'ip' is required for IP subnet resources") + attributes['ip'] = kwargs['ip'] + elif resource_type in ['exchange_fqdn_resources', 'exchange_wildcard_fqdn_resources']: + if 'domain' not in kwargs: + raise ValueError("'domain' is required for FQDN and wildcard FQDN resources") + domain = kwargs['domain'] + if resource_type == 'exchange_wildcard_fqdn_resources' and not domain.startswith('*.'): + raise ValueError("Domain for wildcard FQDN resources must start with '*.'") + attributes['domain'] = domain + + # Process optional parameters + for key, value in kwargs.items(): + if key in allowed_params and key not in attributes: + if not isinstance(value, allowed_params[key]): + raise TypeError(f"{key} must be of type {allowed_params[key].__name__}") + if key == 'tags': + if not all(isinstance(tag, str) for tag in value): + raise TypeError("All tags must be strings") + if key == 'protocols': + valid_protocols = [['TCP'], ['UDP'], ['TCP', 'UDP'], ['ICMP'], None] + if value not in valid_protocols: + raise ValueError(f"Invalid protocols. Must be one of: {valid_protocols}") + attributes[key] = value + if key == 'port_ranges': + # Check if protocols are set and not ICMP or None + if 'protocols' not in attributes or attributes['protocols'] in [['ICMP'], None]: + raise ValueError("Port ranges cannot be specified when protocol is ICMP or None") + + parsed_ranges = [] + for range_str in value: + if isinstance(range_str, int): + parsed_ranges.append({'lower_limit': range_str, 'upper_limit': range_str}) + elif isinstance(range_str, str): + if '-' in range_str: + lower, upper = map(int, range_str.split('-')) + if lower > upper: + raise ValueError(f"Invalid port range: {range_str}. Lower limit must be less than or equal to upper limit.") + parsed_ranges.append({'lower_limit': lower, 'upper_limit': upper}) + else: + port = int(range_str) + parsed_ranges.append({'lower_limit': port, 'upper_limit': port}) + else: + raise ValueError(f"Invalid port range format: {range_str}") + attributes[key] = parsed_ranges + else: + attributes[key] = value + + data = { + "data": { + "type": resource_type, + "attributes": attributes, + "relationships": { + "exchange_site": { + "data": { + "id": site_id, + "type": "exchange_sites" + } + } + } + } + } + + ncm = self.session.post(post_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + if ncm.status_code == 201: + return ncm.json()['data'] + else: + return result + + def update_exchange_resource(self, resource_id: str, **kwargs) -> dict: #site_id: str = None, site_name: str = None, + """ + Updates an exchange resource. + + :param resource_id: ID of the exchange resource to update. + :type resource_id: str + :param site_id: NCX Site ID of the resource. Optional if site_name is provided. + :type site_id: str + :param site_name: Name of the NCX Site of the resource. Optional if site_id is provided. + :type site_name: str + :param kwargs: Optional parameters to update. Can include: + - name: New name for the resource. + - protocols: List of protocols (e.g., ['TCP'], ['UDP'], ['TCP', 'UDP'], or ['ICMP']). + - tags: List of tags for the resource. + - domain: Domain name for FQDN or wildcard FQDN resources. + - ip: IP address for IP subnet resources. + - static_prime_ip: Static prime IP for the resource. + - port_ranges: List of port ranges. Each range can be an int, a string (e.g., '80' or '8000-8080'). + Will be converted to a list of dicts with 'lower_limit' and 'upper_limit'. + Not allowed when protocol is ICMP or None. + :return: The updated exchange resource data if successful, error message otherwise. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If an invalid parameter or value is provided, + or if port ranges are provided with ICMP protocol or no protocol. + :raises LookupError: If no site is found when searching by site_name. + """ + call_type = 'Update Exchange Resource' + + if not isinstance(resource_id, str): + raise TypeError("resource_id must be a string") + + # Raise an error if resource_type is provided + if 'resource_type' in kwargs: + raise ValueError("resource_type cannot be updated after resource creation") + + # Get current resource data + current_resource = self.get_exchange_resources(resource_id=resource_id)[0] + resource_type = current_resource['type'] + site_id = current_resource['relationships']['exchange_site']['data']['id'] + + put_url = f'{self.base_url}/beta/exchange_resources/{resource_id}' + + allowed_params = { + 'name': str, + 'protocols': (list, type(None)), + 'tags': list, + 'domain': str, + 'ip': str, + 'static_prime_ip': str, + 'port_ranges': (list, type(None)) + } + + attributes = current_resource['attributes'] + + # Process optional parameters + for key, value in kwargs.items(): + if key in allowed_params: + if not isinstance(value, allowed_params[key]): + raise TypeError(f"{key} must be of type {allowed_params[key].__name__}") + if key == 'tags': + if not all(isinstance(tag, str) for tag in value): + raise TypeError("All tags must be strings") + if key == 'protocols': + valid_protocols = [['TCP'], ['UDP'], ['TCP', 'UDP'], ['ICMP'], None] + if value not in valid_protocols: + raise ValueError(f"Invalid protocols. Must be one of: {valid_protocols}") + # if protocols is set to ICMP or None, port_ranges must be None + if value in [['ICMP'], None]: + attributes['port_ranges'] = None + if key == 'port_ranges': + # Check if protocols are set and not ICMP or None + if 'protocols' not in attributes or attributes['protocols'] in [['ICMP'], None]: + raise ValueError("Port ranges cannot be specified when protocol is ICMP or None") + + parsed_ranges = [] + for range_str in value: + if isinstance(range_str, int): + parsed_ranges.append({'lower_limit': range_str, 'upper_limit': range_str}) + elif isinstance(range_str, str): + if '-' in range_str: + lower, upper = map(int, range_str.split('-')) + if lower > upper: + raise ValueError(f"Invalid port range: {range_str}. Lower limit must be less than or equal to upper limit.") + parsed_ranges.append({'lower_limit': lower, 'upper_limit': upper}) + else: + port = int(range_str) + parsed_ranges.append({'lower_limit': port, 'upper_limit': port}) + else: + raise ValueError(f"Invalid port range format: {range_str}") + value = parsed_ranges + attributes[key] = value + + data = { + "data": { + "type": resource_type, + "id": resource_id, + "attributes": attributes, + "relationships": { + "exchange_site": { + "data": { + "type": "exchange_sites", + "id": site_id + } + } + } + } + } + + ncm = self.session.put(put_url, data=json.dumps(data)) + result = self._return_handler(ncm.status_code, ncm.json(), call_type) + if ncm.status_code == 200: + return ncm.json()['data'] + else: + return result + + def delete_exchange_resource(self, resource_id: str = None, site_name: str = None, site_id: str = None) -> list: + """ + Deletes exchange resources. + + :param resource_id: ID of the exchange resource to delete. Optional if site_name or site_id is provided. + :type resource_id: str, optional + :param site_name: Name of the exchange site to filter resources by. Optional if resource_id or site_id is provided. + :type site_name: str, optional + :param site_id: ID of the exchange site to filter resources by. Optional if resource_id or site_name is provided. + :type site_id: str, optional + :return: The response from the DELETE request. + :raises TypeError: If the type of any parameter is incorrect. + :raises ValueError: If none of resource_id, site_name, or site_id is provided. + :raises LookupError: If no site is found when searching by site_name. + """ + call_type = 'Delete Exchange Resource' + + if not resource_id and not site_name and not site_id: + raise ValueError("Either resource_id, site_name, or site_id must be provided") + + resource_ids = [] + + if resource_id: + if not isinstance(resource_id, str): + raise TypeError("resource_id must be a string") + resource_ids.append(resource_id) + else: + if site_name: + if not isinstance(site_name, str): + raise TypeError("site_name must be a string") + sites = self.get_exchange_sites(name=site_name) + if not sites: + raise LookupError(f"No site found with name: {site_name}") + site_id = sites[0]['id'] + elif site_id and not isinstance(site_id, str): + raise TypeError("site_id must be a string") + + resources = self.get_exchange_resources(site_id=site_id) + resource_ids = [resource['id'] for resource in resources] + + results = [] + for rid in resource_ids: + delete_url = f'{self.base_url}/beta/exchange_resources/{rid}' + ncm = self.session.delete(delete_url) + if ncm.status_code == 204: + results.append({'resource_id': rid, 'status': 'deleted'}) + else: + results.append({'resource_id': rid, 'status': 'error'}) + + return results + + def get_account_authorizations(self, **kwargs): + """ + Get account authorizations + + Args: + **kwargs: Optional parameters for filtering, sorting, and pagination + Supported parameters include: + - fields: Comma-separated list of fields to return + - user: Filter by user + - sort: Field to sort by + - limit: Number of records to return + - search: Search term to filter results + + Returns: + List of account authorization objects + """ + get_url = f'{self.base_url}/beta/account_authorizations' + call_type = 'Account Authorizations' + allowed_params = ['fields', 'user', 'sort', 'limit'] + params = self.__parse_kwargs(kwargs, allowed_params) + + return self.__get_json(get_url, call_type, params=params) + + def put_account_authorizations(self, authorization_id: str, account_authorization: dict): + """ + Update an account authorization role + + Args: + authorization_id: ID of the authorization to update + account_authorization: Account authorization object + + Returns: + Server response + """ + put_url = f'{self.base_url}/beta/account_authorizations/{authorization_id}' + call_type = 'Account Authorization' + ncm = self.session.put(put_url, json=account_authorization) + return self._return_handler(ncm.status_code, ncm.json(), call_type) + + def update_user_role(self, email: str, new_role: str) -> dict: + """ + Updates the role of a user in NCM. + + Args: + email (str): Email address of the user + new_role (str): New role to assign to the user + + Returns: + dict: Response from the API containing the updated authorization + """ + try: + # Find user + users = self.get_users(email=email) + if not users: + raise ValueError(f"User not found: {email}") + + user_id = users[0]['id'] + + # Get account authorizations + auths = self.get_account_authorizations(user=user_id) + if not auths: + raise ValueError(f"No account authorization found for: {email}") + + account_auth_data = auths[0] + account_auth_id = account_auth_data['id'] + + # Update authorization + account_auth = { + "data": { + **account_auth_data, + "attributes": { + **account_auth_data["attributes"], + "role": new_role + } + } + } + call_type = 'Update User Role' + response = self.put_account_authorizations(account_auth_id, account_auth) + return response + + except Exception as e: + self.log('error', f"Error updating role for {email}: {str(e)}") + raise + + # def get_group_modem_upgrade_jobs(self, **kwargs): + # """ + # Returns users with details. + # :param kwargs: A set of zero or more allowed parameters + # in the allowed_params list. + # :return: A list of users with details. + # """ + # call_type = 'Group Modem Upgrades' + # get_url = f'{self.base_url}/beta/group_modem_upgrade_jobs' + + # allowed_params = ['id', + # 'group_id', + # 'module_id', + # 'carrier_id', + # 'overwrite', + # 'active_only', + # 'upgrade_only', + # 'batch_size', + # 'created_at', + # 'created_at__lt', + # 'created_at__lte', + # 'created_at__gt', + # 'created_at__gte', + # 'created_at__ne', + # 'updated_at', + # 'updated_at__lt', + # 'updated_at__lte', + # 'updated_at__gt', + # 'updated_at__gte', + # 'updated_at__ne', + # 'available_version', + # 'modem_count', + # 'success_count', + # 'failed_count', + # 'statuscarrier_name', + # 'module_name', + # 'type', + # 'fields', + # 'limit', + # 'sort'] + + # if "search" not in kwargs.keys(): + # params = self.__parse_kwargs(kwargs, allowed_params) + # else: + # if kwargs['search']: + # params = self.__parse_search_kwargs(kwargs, allowed_params) + # else: + # params = self.__parse_kwargs(kwargs, allowed_params) + # return self.__get_json(get_url, call_type, params=params) + + # def get_group_modem_upgrade_job(self, job_id, **kwargs): + # """ + # Returns users with details. + # :param job_id: The ID of the job + # :param kwargs: A set of zero or more allowed parameters + # in the allowed_params list. + # :return: A list of users with details. + # """ + # call_type = 'Group Modem Upgrades' + # get_url = f'{self.base_url}/beta/group_modem_upgrade_jobs/{job_id}' + + # allowed_params = ['id', + # 'group_id', + # 'module_id', + # 'carrier_id', + # 'overwrite', + # 'active_only', + # 'upgrade_only', + # 'batch_size', + # 'created_at', + # 'updated_at', + # 'available_version', + # 'modem_count', + # 'success_count', + # 'failed_count', + # 'statuscarrier_name', + # 'module_name', + # 'type', + # 'fields', + # 'limit', + # 'sort'] + + # if "search" not in kwargs.keys(): + # params = self.__parse_kwargs(kwargs, allowed_params) + # else: + # if kwargs['search']: + # params = self.__parse_search_kwargs(kwargs, allowed_params) + # else: + # params = self.__parse_kwargs(kwargs, allowed_params) + # return self.__get_json(get_url, call_type, params=params) + + # def get_group_modem_upgrade_summary(self, **kwargs): + # """ + # Returns users with details. + # :param kwargs: A set of zero or more allowed parameters + # in the allowed_params list. + # :return: A list of users with details. + # """ + # call_type = 'Group Modem Upgrades' + # get_url = f'{self.base_url}/beta/group_modem_upgrade_jobs' + + # allowed_params = ['group_id', + # 'module_id', + # 'module_name', + # 'upgradable_modems', + # 'up_to_date_modems', + # 'summary_data', + # 'type', + # 'fields', + # 'limit', + # 'sort'] + + # if "search" not in kwargs.keys(): + # params = self.__parse_kwargs(kwargs, allowed_params) + # else: + # if kwargs['search']: + # params = self.__parse_search_kwargs(kwargs, allowed_params) + # else: + # params = self.__parse_kwargs(kwargs, allowed_params) + # return self.__get_json(get_url, call_type, params=params) + + # def get_group_modem_upgrade_device_summary(self, **kwargs): + # """ + # Returns users with details. + # :param kwargs: A set of zero or more allowed parameters + # in the allowed_params list. + # :return: A list of users with details. + # """ + # call_type = 'Group Modem Upgrades' + # get_url = f'{self.base_url}/beta/group_modem_upgrade_jobs' + + # allowed_params = ['group_id', + # 'module_id', + # 'carrier_id', + # 'overwrite', + # 'active_only', + # 'upgrade_only', + # 'router_name', + # 'net_device_name', + # 'current_version', + # 'type', + # 'fields', + # 'limit', + # 'sort'] + + # if "search" not in kwargs.keys(): + # params = self.__parse_kwargs(kwargs, allowed_params) + # else: + # if kwargs['search']: + # params = self.__parse_search_kwargs(kwargs, allowed_params) + # else: + # params = self.__parse_kwargs(kwargs, allowed_params) + # return self.__get_json(get_url, call_type, params=params) + + # def create_modem_upgrade(self, group_id: int, carrier: str, modem_type_name: str, operation: str, overwrite: bool = False, connection_states: list = None, **kwargs) -> dict: + # """ + # Creates a new modem upgrade job. + + # :param group_id: ID of the group to target for the modem upgrade. + # :type group_id: int + # :param carrier: Targeted carrier (e.g., "att"). + # :type carrier: str + # :param modem_type_name: Module name associated with module_id (e.g., "LP4"). + # :type modem_type_name: str + # :param operation: Operation type [preview|upgrade|cancel]. + # :type operation: str + # :param overwrite: Overwrite modem firmware if on the same version already. Defaults to False. + # :type overwrite: bool + # :param connection_states: The targeted net devices connection state. Optional. + # :type connection_states: list, optional + # :param kwargs: Additional optional parameters to include in the request. + # :return: The created modem upgrade job data if successful, error message otherwise. + # :raises TypeError: If the type of any parameter is incorrect. + # :raises ValueError: If required parameters are missing or if an invalid parameter or value is provided. + # """ + # call_type = 'Create Modem Upgrade' + + # # Type checking for required parameters + # if not isinstance(group_id, int): + # raise TypeError("group_id must be an integer") + # if not isinstance(carrier, str): + # raise TypeError("carrier must be a string") + # if not isinstance(modem_type_name, str): + # raise TypeError("modem_type_name must be a string") + # if not isinstance(operation, str): + # raise TypeError("operation must be a string") + # if not isinstance(overwrite, bool): + # raise TypeError("overwrite must be a boolean") + + # # Validate operation value + # valid_operations = ["preview", "upgrade", "cancel"] + # if operation not in valid_operations: + # raise ValueError(f"operation must be one of: {valid_operations}") + + # # Validate carrier and modem_type_name are not empty + # if not carrier.strip(): + # raise ValueError("carrier cannot be empty") + # if not modem_type_name.strip(): + # raise ValueError("modem_type_name cannot be empty") + + # post_url = f'{self.base_url}/beta/modem_upgrades' + + # # Build attributes dictionary with required fields + # attributes = { + # 'carrier': carrier, + # 'modem_type_name': modem_type_name, + # 'operation': operation, + # 'overwrite': overwrite + # } + + # # Add connection_states if provided + # if connection_states is not None: + # if not isinstance(connection_states, list): + # raise TypeError("connection_states must be a list") + # if not all(isinstance(state, str) for state in connection_states): + # raise TypeError("All connection_states must be strings") + # attributes['connection_states'] = connection_states + + # # Add any additional parameters from kwargs directly to attributes + # for key, value in kwargs.items(): + # attributes[key] = value + + # data = { + # "data": { + # "type": "modem_upgrades", + # "attributes": attributes, + # "relationships": { + # "group": { + # "data": { + # "type": "groups", + # "id": group_id + # } + # } + # } + # } + # } + + # ncm = self.session.post(post_url, data=json.dumps(data)) + # result = self._return_handler(ncm.status_code, ncm.json(), call_type) + # if ncm.status_code == 201: + # return ncm.json()['data'] + # else: + # return result + + + +class NcmClientv2v3: + """ + Unified NCM client that provides access to both v2 and v3 APIs. + Uses composition instead of inheritance for better maintainability. + """ + + def __init__(self, + api_keys=None, + api_key=None, + log_events=True, + logger=None, + retries=5, + retry_backoff_factor=2, + retry_on=None, + base_url=None, + base_url_v3=None): + """ + :param api_keys: Dictionary of API credentials (apiv2). + Optional, but must be set before calling functions. + :type api_keys: dict + :param api_key: API key for apiv3. + Optional, but must be set before calling functions. + :type api_key: str + """ + api_keys = api_keys or {} + apiv3_key = api_keys.pop('token', None) or api_key + + # Initialize clients using composition + self._v2_client = None + self._v3_client = None + + if api_keys: + self._v2_client = NcmClientv2(api_keys=api_keys, + log_events=log_events, + logger=logger, + retries=retries, + retry_backoff_factor=retry_backoff_factor, + retry_on=retry_on, + base_url=base_url) + + if apiv3_key: + base_url = base_url_v3 if api_keys else base_url + self._v3_client = NcmClientv3(api_key=apiv3_key, + log_events=log_events, + logger=logger, + retries=retries, + retry_backoff_factor=retry_backoff_factor, + retry_on=retry_on, + base_url=base_url) + + # For backwards compatibility + self.v2 = self._v2_client + self.v3 = self._v3_client + + def __getattr__(self, name: str) -> Any: + """ + Delegate method calls to the appropriate client. + Prioritizes v2 for router-related methods (since router IDs are v2-specific), + otherwise prioritizes v3 over v2 for method resolution. + + :param name: Name of the attribute to get + :type name: str + :return: The requested attribute + :raises AttributeError: If the attribute doesn't exist in either client + """ + # Router-related methods should use v2 since router IDs are v2-specific + router_methods = [ + 'get_router_appdata', 'get_router_appdata_value', + 'get_router_by_id', 'get_router_by_name', 'get_routers', + 'get_router_alerts', 'get_router_logs', 'get_router_state_samples', + 'get_router_stream_usage_samples', 'get_routers_for_account', + 'get_routers_for_group', 'rename_router_by_id', 'rename_router_by_name', + 'assign_router_to_group', 'remove_router_from_group', 'assign_router_to_account', + 'delete_router_by_id', 'delete_router_by_name', 'reboot_device', + 'set_lan_ip_address', 'set_custom1', 'set_custom2', 'set_admin_password', + 'set_router_name', 'set_router_description', 'set_router_asset_id', + 'set_ethernet_wan_ip', 'add_custom_apn', 'set_ncm_api_keys_by_router', + 'set_router_fields', 'copy_router_configuration', 'resume_updates_for_router', + 'get_net_devices_for_router', 'get_net_devices_for_router_by_mode', + 'get_historical_locations', 'get_historical_locations_for_date', + 'create_location', 'delete_location_for_router', 'create_speed_test_mdm' + ] + + # For router-related methods, try v2 first + if name in router_methods: + if self._v2_client and hasattr(self._v2_client, name): + return getattr(self._v2_client, name) + if self._v3_client and hasattr(self._v3_client, name): + return getattr(self._v3_client, name) + else: + # For other methods, try v3 first, then v2 + if self._v3_client and hasattr(self._v3_client, name): + return getattr(self._v3_client, name) + if self._v2_client and hasattr(self._v2_client, name): + return getattr(self._v2_client, name) + + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + + def __dir__(self) -> list: + """ + Return a list of available attributes and methods. + Combines methods from both v2 and v3 clients. + + :return: List of available attribute names + :rtype: list + """ + attrs = set(super().__dir__()) + if self._v2_client: + attrs.update(dir(self._v2_client)) + if self._v3_client: + attrs.update(dir(self._v3_client)) + return sorted(attrs) + + def get_available_methods(self) -> list: + """ + Get a list of all available methods from both clients. + Useful for introspection and debugging. + + :return: List of available method names + :rtype: list + """ + methods = set() + if self._v2_client: + v2_methods = [method for method in dir(self._v2_client) + if not method.startswith('_') and callable(getattr(self._v2_client, method))] + methods.update(v2_methods) + if self._v3_client: + v3_methods = [method for method in dir(self._v3_client) + if not method.startswith('_') and callable(getattr(self._v3_client, method))] + methods.update(v3_methods) + return sorted(methods) + + def has_v2_client(self) -> bool: + """ + Check if v2 client is available. + + :return: True if v2 client is available + :rtype: bool + """ + return self._v2_client is not None + + def has_v3_client(self) -> bool: + """ + Check if v3 client is available. + + :return: True if v3 client is available + :rtype: bool + """ + return self._v3_client is not None + + +class NcmClient: + """ + This NCM Client class provides functions for interacting with = + the Cradlepoint NCM API. Full documentation of the Cradlepoint API can be + found at: https://developer.cradlepoint.com + """ + + def __new__(cls, api_keys=None, api_key=None, **kwargs): + api_keys = {**(api_keys or {})} + apiv3_key = api_keys.pop('token', None) or api_key + v2 = bool(api_keys) + v3 = bool(apiv3_key) + if v2 and v3: + return NcmClientv2v3(api_keys=api_keys, api_key=apiv3_key, **kwargs) + if v2 or not (v2 or v3): + return NcmClientv2(api_keys=api_keys, **kwargs) + else: + return NcmClientv3(api_key=apiv3_key, **kwargs) + + +# Singleton instance for easy module-level access +_ncm_instance = None + + +def _load_api_keys_from_env() -> Tuple[Optional[Dict[str, str]], Optional[str]]: + """ + Load API keys from environment variables if available. + + :return: Tuple of (v2_api_keys_dict, v3_api_key_string) + :rtype: tuple + """ + # Load v2 API keys from environment + v2_keys = {} + env_v2_keys = { + 'X-CP-API-ID': os.environ.get('X_CP_API_ID'), + 'X-CP-API-KEY': os.environ.get('X_CP_API_KEY'), + 'X-ECM-API-ID': os.environ.get('X_ECM_API_ID'), + 'X-ECM-API-KEY': os.environ.get('X_ECM_API_KEY') + } + + # Only include keys that are actually set + for key, value in env_v2_keys.items(): + if value: + v2_keys[key] = value + + # Load v3 API key from environment + v3_key = os.environ.get('NCM_API_TOKEN') or os.environ.get('TOKEN') + + # Return None for empty dictionaries/keys + v2_result = v2_keys if v2_keys else None + v3_result = v3_key if v3_key else None + + return v2_result, v3_result + + +def get_ncm_instance() -> Union['NcmClientv2', 'NcmClientv3', 'NcmClientv2v3']: + """ + Get the singleton NCM instance. + Automatically initializes from environment variables if no instance exists. + + Environment variables: + - X_CP_API_ID: CP API ID for v2 API + - X_CP_API_KEY: CP API Key for v2 API + - X_ECM_API_ID: ECM API ID for v2 API + - X_ECM_API_KEY: ECM API Key for v2 API + - NCM_API_TOKEN or TOKEN: Bearer token for v3 API + + :param api_keys: Dictionary of API credentials (apiv2). Optional. + :type api_keys: dict + :param api_key: API key for apiv3. Optional. + :type api_key: str + :param kwargs: Additional arguments passed to NcmClient + :return: NCM client instance + :rtype: NcmClientv2, NcmClientv3, or NcmClientv2v3 + """ + global _ncm_instance + if _ncm_instance is None: + # Try to auto-initialize from environment variables + env_v2_keys, env_v3_key = _load_api_keys_from_env() + if env_v2_keys or env_v3_key: + _ncm_instance = NcmClient(api_keys=env_v2_keys, api_key=env_v3_key, log_events=False) + else: + raise RuntimeError("No NCM instance has been created yet. Call ncm.set_api_keys() first or set environment variables (X_CP_API_ID, X_CP_API_KEY, X_ECM_API_ID, X_ECM_API_KEY, NCM_API_TOKEN).") + return _ncm_instance + + +def set_api_keys(api_keys: Optional[Dict[str, str]] = None, + api_key: Optional[str] = None, + **kwargs: Any) -> Union['NcmClientv2', 'NcmClientv3', 'NcmClientv2v3']: + """ + Set API keys for the singleton NCM instance. + This is a convenience function that creates or updates the singleton instance. + This function is backward compatible and doesn't interfere with existing + instance.set_api_keys() methods. + Automatically loads API keys from environment variables if not provided. + + Environment variables: + - X_CP_API_ID: CP API ID for v2 API + - X_CP_API_KEY: CP API Key for v2 API + - X_ECM_API_ID: ECM API ID for v2 API + - X_ECM_API_KEY: ECM API Key for v2 API + - NCM_API_TOKEN or TOKEN: Bearer token for v3 API + + :param api_keys: Dictionary of API credentials (apiv2). Optional. + :type api_keys: dict + :param api_key: API key for apiv3. Optional. + :type api_key: str + :param kwargs: Additional arguments passed to NcmClient + :return: NCM client instance + :rtype: NcmClientv2, NcmClientv3, or NcmClientv2v3 + """ + global _ncm_instance + + # Load from environment if not provided + if not api_keys and not api_key: + env_v2_keys, env_v3_key = _load_api_keys_from_env() + api_keys = env_v2_keys + api_key = env_v3_key + + # Create a new instance with the provided keys + # This uses the existing NcmClient logic which handles key validation + _ncm_instance = NcmClient(api_keys=api_keys, api_key=api_key, **kwargs) + return _ncm_instance + + +def set_ncm_instance(instance: Union['NcmClientv2', 'NcmClientv3', 'NcmClientv2v3']) -> None: + """ + Set the singleton NCM instance (useful for testing or custom configuration). + + :param instance: NCM client instance to use as singleton + :type instance: NcmClientv2, NcmClientv3, or NcmClientv2v3 + """ + global _ncm_instance + _ncm_instance = instance + + +def reset_ncm_instance() -> None: + """ + Reset the singleton NCM instance to None. + """ + global _ncm_instance + _ncm_instance = None + + +# Module-level function factory for direct method access +def _create_module_method(method_name: str) -> Any: + """ + Create a module-level function that delegates to the singleton instance. + + :param method_name: Name of the method to create module-level access for + :type method_name: str + :return: Function that delegates to the singleton instance method + :rtype: function + """ + def module_method(*args: Any, **kwargs: Any) -> Any: + instance = get_ncm_instance() + if not hasattr(instance, method_name): + raise AttributeError(f"NCM client has no method '{method_name}'") + return getattr(instance, method_name)(*args, **kwargs) + + module_method.__name__ = method_name + module_method.__doc__ = f"Module-level access to {method_name} method" + return module_method + + +# Module-level method delegation functions +def _delegate_to_instance(method_name: str, *args: Any, **kwargs: Any) -> Any: + """ + Delegate method calls to the singleton instance. + + :param method_name: Name of the method to call + :type method_name: str + :param args: Positional arguments for the method + :param kwargs: Keyword arguments for the method + :return: Result of the method call + :raises AttributeError: If the method doesn't exist on the instance + """ + instance = get_ncm_instance() + if not hasattr(instance, method_name): + # Provide a more helpful error message + if method_name == 'get_exchange_resources': + raise AttributeError(f"NCM client has no method '{method_name}'. This method requires a v3 API client. Please ensure you have a v3 API token configured.") + else: + raise AttributeError(f"NCM client has no method '{method_name}'") + return getattr(instance, method_name)(*args, **kwargs) + + +def _setup_all_module_methods(): + """ + Automatically create module-level functions for all methods in NCM client classes. + This makes all methods available as module-level functions (e.g., ncm.get_routers()). + """ + # Collect all public methods from the client classes + all_methods = set() + + # Get methods from NcmClientv2 + for name in dir(NcmClientv2): + if not name.startswith('_') and callable(getattr(NcmClientv2, name)): + all_methods.add(name) + + # Get methods from NcmClientv3 + for name in dir(NcmClientv3): + if not name.startswith('_') and callable(getattr(NcmClientv3, name)): + all_methods.add(name) + + # Get methods from NcmClientv2v3 + for name in dir(NcmClientv2v3): + if not name.startswith('_') and callable(getattr(NcmClientv2v3, name)): + all_methods.add(name) + + # Exclude methods that are already defined or are special methods + excluded = { + 'set_api_keys', 'get_ncm_instance', 'set_ncm_instance', 'reset_ncm_instance', + '__class__', '__dict__', '__doc__', '__module__', '__weakref__' + } + + # Create module-level functions for each method + for method_name in all_methods: + if method_name in excluded or method_name in globals(): + continue + + # Create a wrapper function that delegates to the instance + # Use default parameter to capture method_name in closure + def make_wrapper(name=method_name): + def wrapper(*args, **kwargs): + return _delegate_to_instance(name, *args, **kwargs) + wrapper.__name__ = name + wrapper.__doc__ = f"Module-level access to {name} method" + return wrapper + + # Set the function in the module's globals + globals()[method_name] = make_wrapper() + + +# Automatically set up all module-level methods +_setup_all_module_methods() + + +# Backward compatibility: Create a submodule-like structure +class _NcmModule: + """ + Backward compatibility class that provides the old import structure. + Allows scripts to use: from ncm import ncm + """ + + # Expose all the main classes + NcmClient = NcmClient + NcmClientv2 = NcmClientv2 + NcmClientv3 = NcmClientv3 + NcmClientv2v3 = NcmClientv2v3 + BaseNcmClient = BaseNcmClient + + # Expose utility functions as static methods to avoid self parameter issues + @staticmethod + def get_ncm_instance(api_keys: Optional[Dict[str, str]] = None, + api_key: Optional[str] = None, + **kwargs: Any) -> Union['NcmClientv2', 'NcmClientv3', 'NcmClientv2v3']: + """Module-level get_ncm_instance function for backward compatibility.""" + return globals()['get_ncm_instance'](api_keys, api_key, **kwargs) + + @staticmethod + def set_ncm_instance(instance: Union['NcmClientv2', 'NcmClientv3', 'NcmClientv2v3']) -> None: + """Module-level set_ncm_instance function for backward compatibility.""" + return globals()['set_ncm_instance'](instance) + + # Expose reset_ncm_instance as a static method to avoid self parameter issues + @staticmethod + def reset_ncm_instance() -> None: + """Module-level reset_ncm_instance function for backward compatibility.""" + return globals()['reset_ncm_instance']() + + # Expose set_api_keys as a static method to avoid self parameter issues + @staticmethod + def set_api_keys(api_keys: Optional[Dict[str, str]] = None, + api_key: Optional[str] = None, + **kwargs: Any) -> Union['NcmClientv2', 'NcmClientv3', 'NcmClientv2v3']: + """ + Module-level set_api_keys function for backward compatibility. + + :param api_keys: Dictionary of API credentials (apiv2). Optional. + :type api_keys: dict + :param api_key: API key for apiv3. Optional. + :type api_key: str + :param kwargs: Additional arguments passed to NcmClient + :return: NCM client instance + :rtype: NcmClientv2, NcmClientv3, or NcmClientv2v3 + """ + return globals()['set_api_keys'](api_keys, api_key, **kwargs) + + # Expose all module-level methods + def __getattr__(self, name: str) -> Any: + """ + Delegate to module-level functions. + + :param name: Name of the attribute to get + :type name: str + :return: The requested attribute + :raises AttributeError: If the attribute doesn't exist + """ + if hasattr(sys.modules[__name__], name): + return getattr(sys.modules[__name__], name) + raise AttributeError(f"module 'ncm.ncm' has no attribute '{name}'") + + def __dir__(self) -> list: + """ + Return available attributes. + + :return: List of available attribute names + :rtype: list + """ + return sorted(set(dir(sys.modules[__name__]))) + + +# Create the backward compatibility object +ncm = _NcmModule()