feat 🔌 shelly-mcp + TP17 Shelly-Migration VLAN1→VLAN30; TP14.3 dormante Stacks bereitgestellt
TP17 (neu): shelly-mcp (eigenbau, FastMCP StreamableHTTP :3030 + Status/Health-Seite, in .claude/launch.json) mit Tools discover/get/set_sta[guarded]/verify/reservation_plan. Recon: 16 Shellys live, alle Gen3/4 (RPC WiFi.SetConfig, auth=off). Inventar-Fix: s6/kueche_kuehlschrank MAC 0c:72… → real d0:cf:13:d8:18:fc. TP14.3: 7 dormante Stacks als .new.yml bereitgestellt (nur lokal, gitignored) — SSoT in docs/teilprojekte.md nachgezogen. mcp/INVENTORY.md: shelly-mcp-Zeile + Richtungsentscheid „MCPs zentral, nicht Client; MikroMCP+shelly-mcp sind pragmatische Interims, später zusammenziehen". .gitignore: __pycache__/*.pyc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "shelly-mcp",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["mcp/shelly/server.py"],
|
||||
"port": 3030
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
||||
.DS_Store
|
||||
.secrets
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# NAS-VLAN20-Migration: Compose-Files/Inspects enthalten Live-Secrets → nicht committen
|
||||
docs/nas-vlan20/stacks/
|
||||
docs/nas-vlan20/inspect_*.json
|
||||
|
||||
@@ -308,9 +308,10 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung".
|
||||
- **pve-mcp „Failed to connect":** Ursache = **mcp-pve v0.4.8** kann im HTTP-Transport nur **eine** MCP-Session (ein globaler `StreamableHTTPServerTransport`, `sessionIdGenerator` gesetzt) → erster Client nach Boot belegt den Slot, jeder weitere Init (neue Claude-Code-Session, `claude mcp list`-Healthcheck, Reconnect) = `400 "Server already initialized"`. **omada-mcp NICHT betroffen** (frische Session-ID pro Init → Per-Session-Transport). **Fix = Upgrade auf 0.6.1** (Per-Session-Map, `serverFactory()` je Session, gleiche Env-Vars = Drop-in); Stack 202 neu deployed, Container `.20.173` = 0.6.1, `Connected to Proxmox VE 9.0.11`, `tools/list` liefert `pve_*`, **Claude Code ✔ Connected**. 3× init hintereinander = 3× 200 mit verschiedenen session-ids (verifiziert).
|
||||
- **Kollateral-Fund — VLAN20-Host-DNS kaputt (beide Docker-Hosts):** nach dem VLAN20-Umzug zeigten **NAS** *und* **pve-LXC** `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr.io scheitert (`EAI_AGAIN` / 5s-Resolver-Timeout; Egress zu ghcr-IP `140.82.x:443` = OK 31 ms, also **nur Resolver tot**, nicht Egress). Fixes: **LXC** `.20.126` → `pct set 400 --nameserver 10.40.20.1` (Christoph). **NAS** → DSM-„Allgemein"-DNS war `.20.1`, ABER die **Netzwerkschnittstelle zog DNS per DHCP** und überschrieb `resolv.conf`; Fix = Schnittstelle „DNS manuell" `10.40.20.1` (Christoph) → NAS pullt wieder direkt. **Merkregel Synology:** GUI-DNS ≠ was `dockerd` liest (Quelle `/etc/resolv.conf`, per DHCP überschreibbar). **Workaround solange NAS-DNS tot:** `docker save`(LXC, hat DNS) → **gzip** (191-MB-tar reißt am Portainer-Agent-Socket-Proxy „context canceled"; ~70 MB gz geht) → Portainer `POST /endpoints/14/docker/images/load` → Stack-Redeploy `pullImage:false`. Gotcha: laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig; für pve-mcp egal — Proxmox per IP `.41.100`). Memory `ref_pve_mcp_singlesession_and_vlan20_dns`.
|
||||
|
||||
### TP14.3 — Session C: Dormante Stacks vlan20-tauglich + Referenz-Sweep ▸ 🔜 TODO
|
||||
- `transform.py` auf core(43)/openclaw(92)/openclaw2(97)/oc20260307(119)/openspeedtest(74)/speedtest_tracker(80)/technikscheiss(42) → `.new` (42/43 waren `default_internal` → vlan20-only).
|
||||
- Referenz-Sweep `.41→.20`: ap1 bf.lan-DNS (MikroMCP, ap1 via `10.40.10.1`), CLAUDE.md, `~/.claude.json`, `docs/ip-plan.md`, CF-Ingress-Rest.
|
||||
### TP14.3 — Session C: Dormante Stacks vlan20-tauglich + Referenz-Sweep ▸ ⏳ TEIL (Stacks ✅ 2026-07-11)
|
||||
- ✅ **Dormante Stacks bereitgestellt (2026-07-11):** `transform.py` auf core(43)/openclaw(92)/openclaw2(97)/oc20260307(119)/openspeedtest(74)/speedtest_tracker(80)/technikscheiss(42) → **`.new.yml` + `.orig.yml`** in `docs/nas-vlan20/stacks/` (gitignored). **Nicht deployt** (User: „im Bedarfsfall später", Container-Zukunft ungewiss). 42/43 waren `default_internal` → jetzt vlan20-only (DHCP-.20.x). ⚠️ **Deploy-Caveats:** externe Refs bewusst NICHT umgeschrieben (`DB_HOST=.41.122` [pre-existing tote openclaw-DB, in 80+119], `OLLAMA_BASE_URL=.41.129`, `REDIS_URL=.41.111` in 119); **Kollision** `43-core` will `.20.23` = gitea-DB-IP (nicht gleichzeitig deploybar).
|
||||
- 🔜 **Referenz-Sweep `.41→.20`** (offen): ap1 bf.lan-DNS (MikroMCP, ap1 via `10.40.10.1`), CLAUDE.md, `~/.claude.json`, `docs/ip-plan.md`, CF-Ingress-Rest.
|
||||
- ↪️ **Shelly-Geräte-Übersiedelung ausgegliedert → TP17** (die ~26 IoT-Shellys sind eine eigenständige WLAN-Migration, nicht Teil des Docker-Ref-Sweeps).
|
||||
|
||||
### TP14.4 — Session D: CF-Access-Profil für Claude ▸ 🔜 TODO (nicht cutoff-kritisch)
|
||||
- CF-Token „claude code mcp" hat jetzt `Access: Service Tokens: Write`+`Read` → `claude-mcp` rotieren (`POST …/access/service_tokens/{id}/rotate`) → `CF_ACCESS_CLIENT_ID`/`_SECRET` in `mcp/cloudflare/.env` (chmod600). Memory `ref-cf-access-claude-profile`.
|
||||
@@ -350,6 +351,14 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung".
|
||||
- **Umsetzungs-Reihenfolge (für die eigene Session):** (1) **Reachability-Matrix je Host** (pve/NAS/macmini → jede Origin, read-only) BEVOR gruppiert wird; (2) **Interim-Sofortfix:** bf-Tunnel auf **pve-only** (pve erreicht alles außer gateway-dw) → 0×1033, reversibel; (3) reziprokes 3-Tunnel-HA bauen (CNAMEs re-pointen, 6 Connectoren, Token je Tunnel; NAS/macmini-Alt-Token entfernen); (4) tote Origins bereinigen; (5) Access-Strang (WARP + vw); (6) Loop-Gegentest.
|
||||
- Abh.: TP14.1/14.2 ✅. Verwandt: **TP11** (CF-Tunnel-Track-B/C + Access), #1033. SSoT: `docs/cf-tunnel-mapping.md`. Memory `project_cf_tunnel_1033`.
|
||||
|
||||
### TP17 — Shelly-Übersiedelung VLAN1 → VLAN30 (IOT) + `shelly-mcp` ▸ ⏳ WIP (Recon ✅ 2026-07-11) — `mcp/shelly/`
|
||||
- **Ziel:** ~26 Shellys (aktuell untagged VLAN1 `10.40.41.2xx`, weil auf **alt-WLAN cudy**, s. TP3.1/`project_vlan1_dhcp_incident`) auf SSID **`dirtyvader-iot`** (VLAN30, `w2-iot`, WPA2/WPA3-mixed, PSK `74c9L283$669`) umklemmen → DHCP `dhcp-v30`/pool-v30 (live). **Kein netzseitiger Move möglich** (anderes WLAN) → per-Gerät WiFi-Reconfig.
|
||||
- **User-Entscheide (2026-07-11):** (1) **volles `shelly-mcp`** bauen (reusable, nicht nur Skript); (2) **Pilot 2–3, dann Rest**; (3) **DHCP-Reservierungen je MAC** in VLAN30 (stabile IPs für HA/ioBroker/DNS).
|
||||
- ✅ **Recon durch (`mcp/shelly/discover.py`, read-only /shelly-Sweep):** 16 live, **alle Gen3/Gen4** (uniforme RPC-API `WiFi.SetConfig`, auth=off — kein Gen1-Sonderfall). 10 „offline" = **weihnachten_\*** (s1/s2/s3, saisonal ausgesteckt Juli) + s12/s14/s16/s18/s22/s23 (aus/DHCP-drift). ⚠️ **Inventar-Fix:** s6 (kueche_kuehlschrank, `.213`) MAC falsch erfasst (`0c:72:74:36:90:9e`) → real **`d0:cf:13:d8:18:fc`**. s24 hat gar keine MAC.
|
||||
- **Mechanismus je Gerät:** Gen2+ RPC `WiFi.SetConfig {sta:{ssid,pass,enable}}` → Reboot → landet `.30.x`. ⚠️ **Risiken:** falsches PSK → AP-Mode (physischer Reset); Gerät kurz offline; HA/ioBroker-Refs brechen bis re-pointed; WPA3-mixed ggf. zickig auf alter FW; ap1 2,4-GHz-Master `wifi2` `running:false` → Coverage v.a. via ap2 prüfen.
|
||||
- **Offene Schritte:** `shelly-mcp` Server (Tools discover/get/set-sta/verify) fertig + registrieren → VLAN30-DHCP-Reservierungen je MAC anlegen (MikroMCP) → **Pilot** (unkritischer Always-on, z.B. `bad_luefter`/`technik_pve`) → HA/ioBroker-Wiederanbindung verifizieren → Rest in Batches → cudy-Alt-WLAN abbauen → DNS/Inventar/ip-plan nachziehen.
|
||||
- Abh.: SSID/VLAN30 (TP3/TP4 ✅), DHCP-v30 ✅. Verwandt: `project_vlan1_dhcp_incident` (cudy-Rest-Leak), TP5 (IOT-Firewall danach). Memory `project_shelly_migration_tp17`.
|
||||
|
||||
### TP-R — Router-Capabilities-Sweep ▸ FERTIG (`docs/research/routeros-capabilities.md`)
|
||||
- **Leitsatz: nativ vor Container, NAS vor Router** (128MB-Flash-Limit).
|
||||
- **Sofort nativ (~0 RAM):** RoMON · Netwatch · Scheduler + Auto-Backup/Mail · SNMP→externes Monitoring · **mDNS-Repeater** · spectral-scan · **CAPsMAN + 802.11r/k/v (Fast-Roaming statt Mesh)** · Back-to-Home/WireGuard.
|
||||
|
||||
@@ -210,7 +210,7 @@ IOT,10.40.41.209,shelly: s2,28:37:2F:36:F4:7C,,,,weihnachten_terrasse
|
||||
IOT,10.40.41.210,shelly: s3,E4:B0:63:E4:EF:7C,,,,weihnachten_kinderzimmer
|
||||
IOT,10.40.41.211,shelly: s4,E4:B0:63:E4:0F:30,,,,kueche_mikrowelle
|
||||
IOT,10.40.41.212,shelly: s5,D8:85:AC:0C:5C:C8,,,,kueche_backofen
|
||||
IOT,10.40.41.213,shelly: s6,0c:72:74:36:90:9e,,,,kueche_kuehlschrank
|
||||
IOT,10.40.41.213,shelly: s6,d0:cf:13:d8:18:fc,,,,kueche_kuehlschrank
|
||||
IOT,10.40.41.214,shelly: s7,D0:CF:13:D9:94:E8,,,,kueche_tiefkuehlschrank
|
||||
IOT,10.40.41.215,shelly: s8,E4:B0:63:FA:FD:E0,,,,kueche_geschirrspueler
|
||||
IOT,10.40.41.216,shelly: s9,E8:F6:0A:61:88:08,,,,wohnzimmer_sideboard
|
||||
|
||||
|
@@ -3,12 +3,19 @@
|
||||
> **Regel:** Jeder MCP-Server, der für BF läuft, steht **hier** — sonst existiert er nicht.
|
||||
> Kein lokaler Ad-hoc-`npx`-Wildwuchs auf Laptops. Zentral = Docker auf einem Host, FQDN-adressiert.
|
||||
> Wer einen MCP abschaltet, **streicht die Zeile** (oder setzt Status `retired` mit Datum).
|
||||
>
|
||||
> **Richtungsentscheid (User, 2026-07-11):** MCP-Dienste gehören grundsätzlich auf **zentrale
|
||||
> Serverdienste, nicht auf den Client**. Die aktuell **Client-lokal** laufenden (MikroMCP stdio,
|
||||
> shelly-mcp `:3030`) sind bewusste **pragmatische Interims** ("Hauptsache es funktioniert") und
|
||||
> werden **zu einem späteren Zeitpunkt zentral zusammengezogen** (ein Host, Container, FQDN) — Teil
|
||||
> der Control-Plane-Konsolidierung (vgl. TP11 / Komodo-Migration).
|
||||
|
||||
## Aktive / geplante MCPs
|
||||
|
||||
| MCP | Zweck / Geräte | Host | Transport / Endpoint | Image | Config | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **MikroMCP** (AliKarami) | MikroTik hAP ax² ap1/ap2 (RouterOS 7) — WLAN/CAPsMAN, später Router-Cutover | **jetzt: lokal (dieser Mac)** / Ziel: Mac mini `.167` Docker | jetzt **stdio** (`npx mikromcp serve`) / Ziel HTTP `:3000` | `ghcr.io/alikarami/mikromcp:latest` (HTTP) bzw. npm `mikromcp` (stdio) | `mcp/mikromcp/` + Claude-Code user-config | 🟢 **live (stdio), doctor 12/12, in Claude Code ✓Connected** |
|
||||
| **shelly-mcp** (eigenbau, FastMCP) | ~26 Shellys Gen3/4 — VLAN1→VLAN30-Migration (TP17) + Verwaltung | **jetzt: lokal (dieser Mac) `:3030`** / Ziel: zentral (Container) | **StreamableHTTP** `http://127.0.0.1:3030/mcp` (+ Status `/`, Health `/health`) | eigenbau `mcp/shelly/server.py` | `mcp/shelly/` ✓ (`.env` PSK) | 🟢 **live (Interim, Client-lokal). Tools: discover/get/set_sta[guarded]/verify/reservation_plan** |
|
||||
| **pve-mcp** (samik081) | Proxmox VE API (Host `.100`) | DS923 (`.10`), macvlan-IP **`.173`** | SSE `http://10.40.41.173:3000/sse` | `ghcr.io/samik081/mcp-pve:latest` | — (Stack via Portainer) | 🟢 **live, in Claude Code eingetragen** |
|
||||
| **omada-mcp** (jmtvms v0.15) | TP-Link Omada Controller → ES208G (+ künftige Omada-Geräte) | **pve-LXC (`.126`, endpointId=2)**, ipvlan-IP **`.176`** | **StreamableHTTP** `http://10.40.41.176:3000/mcp` (Health `/health`) | `jmtvms/tplink-omada-mcp:latest` | `mcp/omada/` ✓, settings.json ✓ | 🟢 **live (Up, /health 200, MCP-Handshake ✓). Auth = Omada Open API Client-Credentials. Tools nach Claude-Code-Reload.** |
|
||||
| **omada-controller** | Omada Software Controller (keine MCP) | DS923 (`.10`), macvlan-IP **`.175`** | HTTPS `https://10.40.41.175:8043` | `mbentley/omada-controller:6.2` | `omada/docker-compose.yml` | 🟢 **live, configured, Wizard ✅** |
|
||||
|
||||
6
mcp/shelly/.env.example
Normal file
6
mcp/shelly/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# shelly-mcp config — copy to .env (gitignored) and fill in.
|
||||
SHELLY_IOT_SSID=dirtyvader-iot
|
||||
SHELLY_IOT_PSK= # IOT SSID passphrase (from ap1 w2-iot). Required for set_sta.
|
||||
SHELLY_SWEEP=10.40.41.180-250 # discovery range (VLAN1 today)
|
||||
SHELLY_MCP_HOST=127.0.0.1
|
||||
SHELLY_MCP_PORT=3030
|
||||
35
mcp/shelly/README.md
Normal file
35
mcp/shelly/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# shelly-mcp — Shelly-Verwaltung & VLAN1→VLAN30-Migration (TP17)
|
||||
|
||||
Tooling, um die ~26 Shellys von der alten cudy-WLAN/VLAN1 (`10.40.41.2xx`) auf die
|
||||
IOT-SSID **`dirtyvader-iot`** (VLAN30, `dhcp-v30`/pool-v30) umzuklemmen und danach
|
||||
dauerhaft zu verwalten. Steuerung/Status: `docs/teilprojekte.md` → **TP17**.
|
||||
|
||||
## Ist-Stand (Recon 2026-07-11)
|
||||
- **16 live**, alle **Gen3/Gen4** → uniforme RPC-API (`WiFi.SetConfig`), `auth=off`.
|
||||
Kein Gen1-Sonderfall.
|
||||
- **10 nicht erreichbar:** `weihnachten_*` (s1/s2/s3, saisonal ausgesteckt) +
|
||||
s12/s14/s16/s18/s22/s23 (aus / DHCP-drift). s24 hat keine MAC im Inventar.
|
||||
- Recon-Snapshot: `python3 discover.py 10.40.41.180-250 --json`.
|
||||
|
||||
## Migrationsmechanismus (per Gerät)
|
||||
Gen2+ RPC (HTTP POST `http://<ip>/rpc`):
|
||||
```
|
||||
WiFi.SetConfig {"config":{"sta":{"ssid":"dirtyvader-iot","pass":"<PSK>","enable":true}}}
|
||||
```
|
||||
→ `Shelly.Reboot` (oder greift nach kurzer Zeit selbst) → Gerät verschwindet aus VLAN1,
|
||||
kommt per `dhcp-v30` auf `10.40.30.x` (mit MAC-Reservierung = stabile IP).
|
||||
|
||||
⚠️ **Falsches PSK → Gerät geht in AP-Mode (SSID `Shelly*`) → physischer Reset nötig.**
|
||||
Immer erst `WiFi.GetStatus`/`Sys.GetStatus` gegenchecken, Pilot vor Batch.
|
||||
|
||||
## Dateien
|
||||
- `discover.py` — read-only Sweep, identifiziert Shellys per `/shelly`, matcht MAC gegen
|
||||
`inventory/bf.csv`, meldet DHCP-Drift und Inventar-Lücken. Basis des MCP-`discover`-Tools.
|
||||
- `server.py` — (in Arbeit) MCP-Server: `discover` / `get` / `set_sta` (Migration, guarded) / `verify`.
|
||||
|
||||
## Rollout-Reihenfolge (User-Entscheid: Pilot→Rest, Reservierungen je MAC)
|
||||
1. VLAN30-DHCP-Reservierungen je MAC anlegen (MikroMCP `manage_dhcp_lease`).
|
||||
2. **Pilot** 2–3 unkritische Always-on (z.B. `bad_luefter` .233 / `technik_pve` .222) umklemmen.
|
||||
3. VLAN30-Reachability + HA/ioBroker-Wiederanbindung verifizieren.
|
||||
4. Rest in Batches; `weihnachten_*` beim nächsten Einstecken.
|
||||
5. cudy-Alt-WLAN abbauen; DNS/Inventar/`docs/ip-plan.md` nachziehen.
|
||||
124
mcp/shelly/discover.py
Normal file
124
mcp/shelly/discover.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shelly recon: sweep an IPv4 range for Shelly devices and identify them.
|
||||
|
||||
Read-only. Hits each host's unauthenticated `/shelly` endpoint (works on Gen1
|
||||
and Gen2+), collects gen/model/fw/mac, and cross-references the project
|
||||
inventory (inventory/bf.csv) by MAC so DHCP-drifted devices are still matched
|
||||
to their logical name.
|
||||
|
||||
Usage:
|
||||
python3 discover.py # default sweep 10.40.41.200-240
|
||||
python3 discover.py 10.40.41.180-250
|
||||
python3 discover.py --json # machine-readable to stdout
|
||||
"""
|
||||
import csv, json, os, sys, urllib.request, socket
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
INVENTORY = os.path.join(REPO, "inventory", "bf.csv")
|
||||
|
||||
|
||||
def load_inventory():
|
||||
"""MAC(lower, colon) -> {name, ip, section} for shelly rows."""
|
||||
by_mac = {}
|
||||
if not os.path.exists(INVENTORY):
|
||||
return by_mac
|
||||
with open(INVENTORY) as f:
|
||||
for row in csv.DictReader(f):
|
||||
host = (row.get("hostname") or "")
|
||||
if "shelly" not in host.lower():
|
||||
continue
|
||||
mac = (row.get("mac") or "").strip().lower()
|
||||
entry = {"name": (row.get("note") or host).strip(),
|
||||
"shelly_id": host.replace("shelly:", "").strip(),
|
||||
"inv_ip": (row.get("ip") or "").strip(),
|
||||
"mac": mac}
|
||||
if mac:
|
||||
by_mac[mac] = entry
|
||||
return by_mac
|
||||
|
||||
|
||||
def probe(ip, timeout=3.0):
|
||||
"""Return identity dict for a Shelly at ip, or None."""
|
||||
try:
|
||||
req = urllib.request.Request(f"http://{ip}/shelly")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
d = json.load(r)
|
||||
except Exception:
|
||||
return None
|
||||
gen = d.get("gen", 1)
|
||||
mac = (d.get("mac") or "").lower()
|
||||
# normalise MAC to colon form for inventory matching
|
||||
if mac and ":" not in mac and len(mac) == 12:
|
||||
mac = ":".join(mac[i:i+2] for i in range(0, 12, 2))
|
||||
return {
|
||||
"ip": ip,
|
||||
"gen": gen,
|
||||
"model": d.get("model") or d.get("type") or "?",
|
||||
"fw": d.get("fw") or d.get("ver") or "?",
|
||||
"mac": mac,
|
||||
"auth": d.get("auth", d.get("auth_en", None)),
|
||||
"name_dev": d.get("name"),
|
||||
}
|
||||
|
||||
|
||||
def expand(spec):
|
||||
"""'10.40.41.200-240' or '10.40.41.208' -> list of IPs."""
|
||||
if "-" in spec.split(".")[-1]:
|
||||
base, rng = spec.rsplit(".", 1)
|
||||
lo, hi = rng.split("-")
|
||||
return [f"{base}.{i}" for i in range(int(lo), int(hi) + 1)]
|
||||
return [spec]
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
as_json = "--json" in sys.argv
|
||||
spec = args[0] if args else "10.40.41.200-240"
|
||||
ips = expand(spec)
|
||||
inv = load_inventory()
|
||||
|
||||
found = []
|
||||
with ThreadPoolExecutor(max_workers=32) as ex:
|
||||
for res in ex.map(probe, ips):
|
||||
if res:
|
||||
found.append(res)
|
||||
|
||||
# match to inventory
|
||||
seen_macs = set()
|
||||
for f in found:
|
||||
m = inv.get(f["mac"])
|
||||
f["inv_name"] = m["name"] if m else None
|
||||
f["inv_id"] = m["shelly_id"] if m else None
|
||||
f["inv_ip"] = m["inv_ip"] if m else None
|
||||
f["ip_drifted"] = bool(m and m["inv_ip"] and m["inv_ip"] != f["ip"])
|
||||
if m:
|
||||
seen_macs.add(f["mac"])
|
||||
|
||||
missing = [{"mac": mac, **e} for mac, e in inv.items() if mac not in seen_macs]
|
||||
|
||||
if as_json:
|
||||
print(json.dumps({"found": found, "missing_from_inventory": missing,
|
||||
"swept": spec, "reachable": len(found),
|
||||
"inventory_total": len(inv)}, indent=2))
|
||||
return
|
||||
|
||||
found.sort(key=lambda x: x["inv_id"] or "zzz")
|
||||
print(f"Sweep {spec}: {len(found)} Shellys reachable "
|
||||
f"(inventory has {len(inv)} with MAC)\n")
|
||||
print(f"{'shelly':7} {'ip':15} {'gen':4} {'model':18} {'fw':10} {'name':26} drift")
|
||||
print("-" * 100)
|
||||
for f in found:
|
||||
drift = f"was {f['inv_ip']}" if f["ip_drifted"] else ""
|
||||
print(f"{(f['inv_id'] or '?'):7} {f['ip']:15} g{f['gen']:<3} "
|
||||
f"{str(f['model']):18} {str(f['fw']):10} "
|
||||
f"{str(f['inv_name'] or f.get('name_dev') or '?'):26} {drift}")
|
||||
if missing:
|
||||
print(f"\n{len(missing)} in inventory but NOT reachable in sweep "
|
||||
f"(offline / seasonal / outside range):")
|
||||
for m in sorted(missing, key=lambda x: x["shelly_id"]):
|
||||
print(f" {m['shelly_id']:5} {m['inv_ip']:15} {m['mac']:18} {m['name']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
221
mcp/shelly/server.py
Normal file
221
mcp/shelly/server.py
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""shelly-mcp — MCP server for managing Shellys and the VLAN1→VLAN30 migration (TP17).
|
||||
|
||||
Transport: StreamableHTTP (endpoint /mcp), matching the repo's other MCP servers.
|
||||
Also serves a browser-viewable status page at `/` and JSON health at `/health`,
|
||||
so it works as a local dev server (launch.json / preview_start).
|
||||
|
||||
Config via env (see .env.example):
|
||||
SHELLY_IOT_SSID target IOT SSID (default: dirtyvader-iot)
|
||||
SHELLY_IOT_PSK target IOT PSK (required for set_sta)
|
||||
SHELLY_SWEEP default discovery range (default: 10.40.41.180-250)
|
||||
SHELLY_MCP_HOST bind host (default: 127.0.0.1)
|
||||
SHELLY_MCP_PORT bind port (default: 3030)
|
||||
|
||||
Run: python3 server.py (loads ./.env if present)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
# reuse the read-only recon logic
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import discover # probe(), load_inventory(), expand()
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
# ---- tiny .env loader (no external deps) ----
|
||||
_envfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||
if os.path.exists(_envfile):
|
||||
for _line in open(_envfile):
|
||||
_line = _line.strip()
|
||||
if _line and not _line.startswith("#") and "=" in _line:
|
||||
_k, _v = _line.split("=", 1)
|
||||
os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'"))
|
||||
|
||||
IOT_SSID = os.environ.get("SHELLY_IOT_SSID", "dirtyvader-iot")
|
||||
IOT_PSK = os.environ.get("SHELLY_IOT_PSK", "")
|
||||
SWEEP = os.environ.get("SHELLY_SWEEP", "10.40.41.180-250")
|
||||
HOST = os.environ.get("SHELLY_MCP_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("SHELLY_MCP_PORT", "3030"))
|
||||
|
||||
mcp = FastMCP("shelly-mcp", host=HOST, port=PORT, stateless_http=True,
|
||||
instructions="Manage Shellys and drive the VLAN1→VLAN30 (dirtyvader-iot) "
|
||||
"migration. Read tools are safe; set_sta reconfigures live "
|
||||
"Wi-Fi and requires confirm=true.")
|
||||
|
||||
|
||||
# ---------------- helpers ----------------
|
||||
def _rpc(ip: str, method: str, params: dict | None = None, timeout: float = 6.0) -> dict:
|
||||
"""Gen2+ Shelly RPC over HTTP POST /rpc."""
|
||||
body = json.dumps({"id": 1, "method": method, "params": params or {}}).encode()
|
||||
req = urllib.request.Request(f"http://{ip}/rpc", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def _sweep(spec: str | None = None) -> list[dict]:
|
||||
inv = discover.load_inventory()
|
||||
ips = discover.expand(spec or SWEEP)
|
||||
found = []
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=32) as ex:
|
||||
for res in ex.map(discover.probe, ips):
|
||||
if res:
|
||||
m = inv.get(res["mac"])
|
||||
res["inv_name"] = m["name"] if m else None
|
||||
res["inv_id"] = m["shelly_id"] if m else None
|
||||
res["inv_ip"] = m["inv_ip"] if m else None
|
||||
res["ip_drifted"] = bool(m and m["inv_ip"] and m["inv_ip"] != res["ip"])
|
||||
found.append(res)
|
||||
found.sort(key=lambda x: (x["inv_id"] or "zzz"))
|
||||
return found
|
||||
|
||||
|
||||
# ---------------- MCP tools ----------------
|
||||
@mcp.tool()
|
||||
def discover_shellys(sweep: str | None = None) -> dict:
|
||||
"""Sweep the LAN for Shellys (read-only) and match them to inventory by MAC.
|
||||
|
||||
sweep: IPv4 range like '10.40.41.180-250' or a single IP. Defaults to SHELLY_SWEEP.
|
||||
Returns reachable devices (ip, gen, model, fw, mac, inventory name, ip_drift).
|
||||
"""
|
||||
found = _sweep(sweep)
|
||||
return {"sweep": sweep or SWEEP, "reachable": len(found), "devices": found}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_device(ip: str) -> dict:
|
||||
"""Read a single Shelly's identity + current Wi-Fi STA status (read-only)."""
|
||||
ident = discover.probe(ip)
|
||||
if not ident:
|
||||
return {"ip": ip, "reachable": False}
|
||||
out = {"ip": ip, "reachable": True, "identity": ident}
|
||||
try:
|
||||
out["wifi_status"] = _rpc(ip, "WiFi.GetStatus")
|
||||
out["wifi_config"] = _rpc(ip, "WiFi.GetConfig")
|
||||
except Exception as e:
|
||||
out["rpc_error"] = f"{type(e).__name__}: {e}"
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_sta(ip: str, confirm: bool = False, ssid: str | None = None) -> dict:
|
||||
"""MIGRATION ACTION — point a Shelly's Wi-Fi STA at the IOT SSID, then reboot.
|
||||
|
||||
Reconfigures a LIVE device: it drops off VLAN1 and rejoins on VLAN30 via DHCP.
|
||||
A wrong PSK leaves it in AP-mode (physical reset needed). Therefore:
|
||||
- confirm MUST be true to actually write,
|
||||
- SHELLY_IOT_PSK must be set in the environment.
|
||||
With confirm=false this is a dry-run (returns the exact config that would be sent).
|
||||
"""
|
||||
target_ssid = ssid or IOT_SSID
|
||||
ident = discover.probe(ip)
|
||||
plan = {"ip": ip, "target_ssid": target_ssid,
|
||||
"device": ident, "psk_configured": bool(IOT_PSK)}
|
||||
if not ident:
|
||||
return {**plan, "ok": False, "error": "device not reachable"}
|
||||
if not IOT_PSK:
|
||||
return {**plan, "ok": False, "error": "SHELLY_IOT_PSK not set in environment"}
|
||||
if not confirm:
|
||||
return {**plan, "ok": False, "dry_run": True,
|
||||
"would_send": {"method": "WiFi.SetConfig",
|
||||
"params": {"config": {"sta": {"ssid": target_ssid,
|
||||
"pass": "***", "enable": True,
|
||||
"ipv4mode": "dhcp"}}}}}
|
||||
try:
|
||||
cfg = {"config": {"sta": {"ssid": target_ssid, "pass": IOT_PSK,
|
||||
"enable": True, "ipv4mode": "dhcp"}}}
|
||||
res = _rpc(ip, "WiFi.SetConfig", cfg)
|
||||
reboot = _rpc(ip, "Shelly.Reboot")
|
||||
return {**plan, "ok": True, "set_result": res, "reboot": reboot,
|
||||
"note": "device rebooting; expect it on VLAN30 (10.40.30.x) shortly"}
|
||||
except Exception as e:
|
||||
return {**plan, "ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def verify_on_iot(ip: str) -> dict:
|
||||
"""Verify a device now sits on the IOT SSID / VLAN30 (post-migration check).
|
||||
|
||||
ip = the device's NEW (10.40.30.x) address. Confirms reachability + SSID.
|
||||
"""
|
||||
ident = discover.probe(ip)
|
||||
if not ident:
|
||||
return {"ip": ip, "on_iot": False, "reachable": False}
|
||||
out = {"ip": ip, "reachable": True, "identity": ident}
|
||||
try:
|
||||
st = _rpc(ip, "WiFi.GetStatus")
|
||||
ssid = (st.get("result") or {}).get("ssid")
|
||||
out["ssid"] = ssid
|
||||
out["on_iot"] = (ssid == IOT_SSID) and ip.startswith("10.40.30.")
|
||||
except Exception as e:
|
||||
out["rpc_error"] = f"{type(e).__name__}: {e}"
|
||||
out["on_iot"] = False
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reservation_plan(sweep: str | None = None) -> dict:
|
||||
"""Build the VLAN30 DHCP-reservation plan (MAC → suggested .30.x, keeping last octet).
|
||||
|
||||
Suggests preserving each Shelly's current last octet under 10.40.30.0/24.
|
||||
Output feeds MikroMCP manage_dhcp_lease. Read-only.
|
||||
"""
|
||||
found = _sweep(sweep)
|
||||
rows = []
|
||||
for f in found:
|
||||
last = f["ip"].split(".")[-1]
|
||||
rows.append({"name": f.get("inv_name") or f.get("inv_id") or "?",
|
||||
"mac": f["mac"], "current_ip": f["ip"],
|
||||
"suggested_v30_ip": f"10.40.30.{last}", "gen": f["gen"]})
|
||||
return {"count": len(rows), "server": "dhcp-v30", "reservations": rows}
|
||||
|
||||
|
||||
# ---------------- browser-viewable routes ----------------
|
||||
# NOTE: sync route fns — Starlette runs them in a threadpool, so the blocking
|
||||
# network sweep never freezes the async event loop (which also serves /mcp).
|
||||
@mcp.custom_route("/health", methods=["GET"])
|
||||
def health(_req: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "server": "shelly-mcp",
|
||||
"iot_ssid": IOT_SSID, "psk_configured": bool(IOT_PSK),
|
||||
"mcp_endpoint": "/mcp"})
|
||||
|
||||
|
||||
@mcp.custom_route("/", methods=["GET"])
|
||||
def index(_req: Request) -> HTMLResponse:
|
||||
try:
|
||||
found = _sweep()
|
||||
except Exception as e:
|
||||
found = []
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
else:
|
||||
err = None
|
||||
rows = "".join(
|
||||
f"<tr><td>{f.get('inv_id') or '?'}</td><td>{f['ip']}</td>"
|
||||
f"<td>g{f['gen']}</td><td>{f['model']}</td><td>{f['fw']}</td>"
|
||||
f"<td>{f['mac']}</td><td>{f.get('inv_name') or ''}</td>"
|
||||
f"<td>{'⚠︎ '+(f.get('inv_ip') or '') if f.get('ip_drifted') else ''}</td></tr>"
|
||||
for f in found)
|
||||
html = f"""<!doctype html><meta charset=utf-8>
|
||||
<title>shelly-mcp</title>
|
||||
<style>body{{font:14px/1.5 system-ui;margin:2rem;color:#222}}
|
||||
h1{{font-size:1.3rem}} table{{border-collapse:collapse;margin-top:1rem}}
|
||||
td,th{{border:1px solid #ccc;padding:.25rem .6rem;text-align:left}}
|
||||
th{{background:#f4f4f4}} .m{{color:#666}}</style>
|
||||
<h1>shelly-mcp <span class=m>· TP17 VLAN1→VLAN30</span></h1>
|
||||
<p class=m>MCP endpoint <code>/mcp</code> · target SSID <b>{IOT_SSID}</b> ·
|
||||
PSK {'set' if IOT_PSK else '<b>NOT set</b>'} · sweep <code>{SWEEP}</code></p>
|
||||
{'<p style=color:#b00>'+err+'</p>' if err else ''}
|
||||
<p>{len(found)} Shellys reachable:</p>
|
||||
<table><tr><th>id<th>ip<th>gen<th>model<th>fw<th>mac<th>name<th>drift</tr>{rows}</table>
|
||||
"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
Reference in New Issue
Block a user