From 5be3c9373520728ea171afbe92851e8812c04346 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Wed, 3 Jun 2026 14:37:17 -0400 Subject: [PATCH] Add TP-Link Deco network stats tools --- .env.example | 9 +++ .gitignore | 1 + README.md | 9 +++ package.json | 1 + requirements.txt | 1 + scripts/deco_bridge.py | 156 +++++++++++++++++++++++++++++++++++++++ scripts/setup-python.sh | 11 +++ src/integrations/deco.js | 30 ++++++++ src/server.js | 29 ++++++++ 9 files changed, 247 insertions(+) create mode 100644 requirements.txt create mode 100755 scripts/deco_bridge.py create mode 100755 scripts/setup-python.sh create mode 100644 src/integrations/deco.js diff --git a/.env.example b/.env.example index 9834161..b2b332f 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,12 @@ MACMINI_MCP_HOST=127.0.0.1 MACMINI_MCP_PORT=7331 # Required whenever MACMINI_MCP_HOST is not loopback. # MACMINI_MCP_TOKEN=replace-with-a-long-random-token + +# TP-Link Deco read-only network stats. +# Use the router/deco LAN IP or hostname and the local web/admin password. +# DECO_HOST defaults to the Mac's default gateway when omitted. +# DECO_HOST=192.168.68.1 +# DECO_USERNAME=admin +# DECO_PASSWORD=replace-with-router-password +# DECO_VERIFY_SSL=false +# DECO_TIMEOUT=10 diff --git a/.gitignore b/.gitignore index 979f391..b671470 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.venv/ .env *.log .logs/ diff --git a/README.md b/README.md index 149a33b..fe34823 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ and stays on the local machine. | `contacts_search` | Search contact names and organizations without disclosing contact methods | | `contacts_read` | Read phone and email details for one selected contact | | `contacts_create` | Create a contact with optional email and phone details | +| `deco_get_overview` | Read TP-Link Deco overview stats and firmware | +| `deco_list_clients` | List online Deco clients and current traffic speeds | +| `deco_get_ipv4_status` | Read WAN/LAN IPv4 status | +| `deco_get_firmware` | Read Deco model and firmware version | There are no destructive tools in the initial server. @@ -34,6 +38,7 @@ Requires macOS and Node.js 20 or newer. ```sh npm install +npm run python:install npm run check npm run service:install ``` @@ -89,6 +94,10 @@ Restart after changing `.env`: npm run service:restart ``` +For TP-Link Deco tools, install Python dependencies with `npm run +python:install`, then set `DECO_HOST`, `DECO_USERNAME`, `DECO_PASSWORD`, and +optionally `DECO_VERIFY_SSL=false` in `.env`. + For a harness that launches stdio servers, use: ```json diff --git a/package.json b/package.json index c7154bc..226498b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start:stdio": "node src/stdio.js", "dev": "node --watch src/http.js", "check": "node --check src/*.js && node --check src/integrations/*.js && node --test", + "python:install": "./scripts/setup-python.sh", "service:install": "./scripts/install-service.sh", "service:restart": "./scripts/restart-service.sh", "service:status": "./scripts/status-service.sh", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e695774 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +tplinkrouterc6u==5.21.0 diff --git a/scripts/deco_bridge.py b/scripts/deco_bridge.py new file mode 100755 index 0000000..12d053a --- /dev/null +++ b/scripts/deco_bridge.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +import json +import os +import subprocess +import sys + +from tplinkrouterc6u import TPLinkDecoClient + + +def default_gateway(): + try: + result = subprocess.run( + ["/sbin/route", "-n", "get", "default"], + check=True, + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return None + + for line in result.stdout.splitlines(): + key, _, value = line.partition(":") + if key.strip() == "gateway": + return value.strip() + return None + + +def config(): + host = os.environ.get("DECO_HOST") or default_gateway() + password = os.environ.get("DECO_PASSWORD") + if not password: + raise RuntimeError("DECO_PASSWORD must be set in .env.") + if not host: + raise RuntimeError("DECO_HOST must be set in .env; default gateway detection failed.") + + verify_ssl = os.environ.get("DECO_VERIFY_SSL", "true").lower() not in { + "0", + "false", + "no", + } + timeout = int(os.environ.get("DECO_TIMEOUT", "10")) + return { + "host": host, + "password": password, + "username": os.environ.get("DECO_USERNAME", "admin"), + "verify_ssl": verify_ssl, + "timeout": timeout, + } + + +def client(): + return TPLinkDecoClient(**config()) + + +def device_to_dict(device): + return { + "hostname": device.hostname, + "mac": device.macaddr, + "ip": device.ipaddr, + "connection": getattr(device.type, "value", str(device.type)), + "upSpeed": device.up_speed, + "downSpeed": device.down_speed, + "active": device.active, + } + + +def firmware_to_dict(firmware): + return { + "model": firmware.model, + "hardwareVersion": firmware.hardware_version, + "firmwareVersion": firmware.firmware_version, + } + + +def ipv4_to_dict(status): + return { + "wanMac": status.wan_macaddr, + "wanIp": status.wan_ipv4_ipaddr, + "wanGateway": status.wan_ipv4_gateway, + "wanConnectionType": status.wan_ipv4_conntype, + "wanNetmask": status.wan_ipv4_netmask, + "wanPrimaryDns": status.wan_ipv4_pridns, + "wanSecondaryDns": status.wan_ipv4_snddns, + "lanMac": status.lan_macaddr, + "lanIp": status.lan_ipv4_ipaddr, + "lanNetmask": status.lan_ipv4_netmask, + } + + +def status_to_dict(status, include_clients=True): + data = { + "wanMac": status.wan_macaddr, + "lanMac": status.lan_macaddr, + "wanIp": status.wan_ipv4_addr, + "lanIp": status.lan_ipv4_addr, + "wanGateway": status.wan_ipv4_gateway, + "connectionType": status.conn_type, + "cpuUsage": status.cpu_usage, + "memoryUsage": status.mem_usage, + "clientsTotal": status.clients_total, + "wiredClientsTotal": status.wired_total, + "wifiClientsTotal": status.wifi_clients_total, + "guestClientsTotal": status.guest_clients_total, + "iotClientsTotal": status.iot_clients_total, + "wifi": { + "host2g": status.wifi_2g_enable, + "host5g": status.wifi_5g_enable, + "host6g": status.wifi_6g_enable, + "guest2g": status.guest_2g_enable, + "guest5g": status.guest_5g_enable, + "guest6g": status.guest_6g_enable, + }, + } + if include_clients: + data["clients"] = [device_to_dict(device) for device in status.devices] + return data + + +def run(action): + deco = client() + try: + if action == "overview": + status = deco.get_status() + firmware = deco.get_firmware() + return { + "status": status_to_dict(status, include_clients=False), + "firmware": firmware_to_dict(firmware), + } + if action == "clients": + status = deco.get_status() + return {"clients": [device_to_dict(device) for device in status.devices]} + if action == "ipv4": + return ipv4_to_dict(deco.get_ipv4_status()) + if action == "firmware": + return firmware_to_dict(deco.get_firmware()) + raise RuntimeError(f"Unknown action: {action}") + finally: + try: + deco.logout() + except Exception: + pass + + +def main(): + if len(sys.argv) != 2: + raise RuntimeError("Usage: deco_bridge.py ") + print(json.dumps(run(sys.argv[1]), indent=2)) + + +if __name__ == "__main__": + try: + main() + except Exception as err: + print(json.dumps({"error": str(err)}), file=sys.stderr) + sys.exit(1) diff --git a/scripts/setup-python.sh b/scripts/setup-python.sh new file mode 100755 index 0000000..e3f04ac --- /dev/null +++ b/scripts/setup-python.sh @@ -0,0 +1,11 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +if [[ ! -x .venv/bin/python ]]; then + python3 -m venv .venv +fi + +.venv/bin/python -m pip install -r requirements.txt diff --git a/src/integrations/deco.js b/src/integrations/deco.js new file mode 100644 index 0000000..aaab10b --- /dev/null +++ b/src/integrations/deco.js @@ -0,0 +1,30 @@ +import { execFile } from "node:child_process"; +import { loadEnvFile } from "node:process"; +import { promisify } from "node:util"; + +try { + loadEnvFile(); +} catch (error) { + if (error.code !== "ENOENT") { + throw error; + } +} + +const execFileAsync = promisify(execFile); + +const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname; +const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname; + +export async function getDecoStats(action) { + try { + const { stdout } = await execFileAsync(PYTHON, [BRIDGE, action], { + timeout: 45_000, + maxBuffer: 4 * 1024 * 1024, + env: process.env, + }); + return JSON.parse(stdout); + } catch (error) { + const detail = error.stderr?.trim() || error.message; + throw new Error(`Deco stats request failed. ${detail}`); + } +} diff --git a/src/server.js b/src/server.js index f8da921..ae999d8 100644 --- a/src/server.js +++ b/src/server.js @@ -11,6 +11,7 @@ import { readContact, searchContacts, } from "./integrations/contacts.js"; +import { getDecoStats } from "./integrations/deco.js"; import { createNote, listNotes, readNote } from "./integrations/notes.js"; import { createReminder, @@ -190,5 +191,33 @@ export function createMacMiniMcpServer() { }), ); + server.tool( + "deco_get_overview", + "Read TP-Link Deco overview stats: WAN/LAN addresses, CPU/memory usage, client counts, Wi-Fi enablement, and firmware.", + {}, + handled(() => getDecoStats("overview")), + ); + + server.tool( + "deco_list_clients", + "List online TP-Link Deco clients with hostname, IP, MAC, connection type, and current up/down speeds.", + {}, + handled(() => getDecoStats("clients")), + ); + + server.tool( + "deco_get_ipv4_status", + "Read TP-Link Deco WAN/LAN IPv4 status including gateway, DNS, netmasks, and connection type.", + {}, + handled(() => getDecoStats("ipv4")), + ); + + server.tool( + "deco_get_firmware", + "Read TP-Link Deco model, hardware version, and firmware version.", + {}, + handled(() => getDecoStats("firmware")), + ); + return server; }