Add Hermes shelly MCP for LAN Shelly control.

Gives CT151 native shelly_list/status/cover/pulse tools with safety gates for bad_unten motor and cron-blocked door/garage pulses.
This commit is contained in:
Homelab Cursor 2026-07-16 18:23:23 +02:00
parent 03fd7952af
commit a17031dfbe
2 changed files with 517 additions and 0 deletions

View file

@ -37,3 +37,10 @@ Heizung/PV/Raumtemps MCP fuer Hermes (CT151).
Deploy analog `garage_decke_mcp.py` nach `/root/.hermes/homelab_mcp.py`, dann `systemctl restart hermes-gateway`.
Heizungs-HTTP-API: `infra/heizung-api/pruefstand_web.py` auf pve-mu-3 (`pruefstand-web.service`, Port 8765).
## shelly_mcp.py (16.07.2026)
Tools: `shelly_list`, `shelly_status`, `shelly_cover`, `shelly_pulse`.
Safety: Bad unten Bewegung gesperrt; Tor/Tuersummer nur `confirm=true` und nie in Cron (`HERMES_CRON_SESSION`).
Deploy-Ziel CT151: `/root/.hermes/shelly_mcp.py` + `mcp_servers.shelly` in config.yaml.

510
infra/hermes/shelly_mcp.py Executable file
View file

@ -0,0 +1,510 @@
#!/usr/bin/env python3
"""Shelly MCP — Status und Steuerung der Muldenstein-Shellys per LAN-HTTP.
Inventar fest verdrahtet (CT999 projekte/shelly-inventar.md).
Sicherheit:
- Rollo Bad unten (.96): Bewegung blockiert (Motor defekt)
- Garagentor / Haustuer-Impuls: nur mit confirm=true, nie in Cron
- Cron-Erkennung: HERMES_CRON_SESSION=1
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
TIMEOUT = float(os.environ.get("SHELLY_HTTP_TIMEOUT", "5"))
# alias -> device key
DEVICES: dict[str, dict] = {
"garagentor": {
"ip": "192.168.178.71",
"name": "Garagentor Oeffner",
"gen": 1,
"kind": "pulse",
"aliases": ["garagentor", "tor", "garage_tor", "garage-tor"],
},
"haustuer": {
"ip": "192.168.178.52",
"name": "Haustuer Tuersummer",
"gen": 1,
"kind": "pulse",
"aliases": ["haustuer", "tuersummer", "summer", "tuer", "klingel"],
},
"kueche": {
"ip": "192.168.178.72",
"name": "Rollo Kueche",
"gen": 1,
"kind": "cover",
"api": "roller", # Shelly 2.5 gen1
"aliases": ["kueche", "rollo_kueche", "kitchen"],
},
"computerraum": {
"ip": "192.168.178.26",
"name": "Rollo Computerraum",
"gen": 2,
"kind": "cover",
"api": "cover",
"aliases": ["computerraum", "pc", "buero", "büro"],
},
"gaestezimmer": {
"ip": "192.168.178.38",
"name": "Rollo Gaestezimmer",
"gen": 2,
"kind": "cover",
"api": "cover",
"aliases": ["gaestezimmer", "gastezimmer", "gaeste", "guest"],
},
"bad_unten": {
"ip": "192.168.178.96",
"name": "Rollo Bad unten",
"gen": 2,
"kind": "cover",
"api": "cover",
"motor_broken": True,
"aliases": ["bad_unten", "bad", "badunten", "bathroom"],
},
"garage_decke_a": {
"ip": "192.168.178.86",
"name": "Garage Decke A",
"gen": 1,
"kind": "sensor",
"aliases": ["garage_decke_a", "decke_a", "garage-a"],
},
"garage_decke_b": {
"ip": "192.168.178.78",
"name": "Garage Decke B",
"gen": 1,
"kind": "sensor",
"aliases": ["garage_decke_b", "decke_b", "garage-b"],
},
}
# flatten alias lookup
_ALIAS: dict[str, str] = {}
for key, d in DEVICES.items():
_ALIAS[key.lower()] = key
_ALIAS[d["ip"]] = key
_ALIAS[d["name"].lower()] = key
for a in d.get("aliases", []):
_ALIAS[a.lower()] = key
def _is_cron() -> bool:
v = (os.environ.get("HERMES_CRON_SESSION") or os.environ.get("HERMES_CRON") or "").strip().lower()
return v in ("1", "true", "yes", "on")
def _http_get(url: str) -> dict | str:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
raw = r.read().decode("utf-8", "replace")
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw
def _http_post_json(url: str, payload: dict) -> dict | str:
data = json.dumps(payload).encode()
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"}, method="POST"
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
raw = r.read().decode("utf-8", "replace")
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw
def _resolve(target: str | None) -> tuple[str, dict] | str:
if not target or not str(target).strip():
return "Ziel fehlt. Alias/IP/Name angeben (z.B. kueche, garagentor, 192.168.178.72)."
t = str(target).strip().lower()
# normalize umlauts lightly
t = (
t.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
.replace(" ", "_")
)
key = _ALIAS.get(t)
if not key:
# partial match on name
for k, d in DEVICES.items():
if t in k or t in d["name"].lower().replace(" ", "_"):
key = k
break
if not key:
known = ", ".join(sorted(DEVICES.keys()))
return f"Unbekanntes Ziel {target!r}. Bekannt: {known}"
return key, DEVICES[key]
def _truthy(v) -> bool:
if isinstance(v, bool):
return v
if v is None:
return False
return str(v).strip().lower() in ("1", "true", "yes", "on", "ja")
def tool_list(_args: dict) -> str:
lines = ["Shelly-Inventar Muldenstein (LAN):", ""]
for key, d in DEVICES.items():
caps = d["kind"]
note = ""
if d.get("motor_broken"):
note = " [MOTOR DEFEKT — Bewegung gesperrt]"
if d["kind"] == "pulse":
note += " [Impuls: confirm=true, kein Cron]"
lines.append(
f"- {key}: {d['name']} @ {d['ip']} ({d['kind']}, Gen{d['gen']}){note}"
)
lines.append(f" Aliase: {', '.join(d.get('aliases', [key]))}")
lines.append("")
lines.append("Tools: shelly_list, shelly_status, shelly_cover, shelly_pulse")
lines.append(
"Cron: Rollos OK; Tor/Tuersummer verboten; Bad unten nie bewegen."
)
return "\n".join(lines)
def _status_gen1(d: dict) -> dict:
ip = d["ip"]
settings = _http_get(f"http://{ip}/settings")
status = _http_get(f"http://{ip}/status")
out: dict = {
"ip": ip,
"name": d["name"] if isinstance(settings, dict) and not settings.get("name") else (
settings.get("name") if isinstance(settings, dict) else d["name"]
),
"gen": 1,
"kind": d["kind"],
}
if not isinstance(status, dict):
out["error"] = str(status)
return out
if d["kind"] == "cover" and d.get("api") == "roller":
rollers = status.get("rollers") or []
if rollers:
r0 = rollers[0]
out["cover"] = {
"state": r0.get("state"),
"current_pos": r0.get("current_pos"),
"last_direction": r0.get("last_direction"),
"power": r0.get("power"),
}
if d["kind"] == "pulse":
relays = status.get("relays") or []
out["relay"] = {
"ison": relays[0].get("ison") if relays else None,
"has_timer": relays[0].get("has_timer") if relays else None,
}
if d["kind"] == "sensor":
out["ext_temperature"] = status.get("ext_temperature")
out["mqtt_connected"] = (status.get("mqtt") or {}).get("connected")
out["wifi_rssi"] = (status.get("wifi_sta") or {}).get("rssi")
out["uptime"] = status.get("uptime")
if d.get("motor_broken"):
out["warning"] = "Motor defekt — Bewegung gesperrt"
return out
def _status_gen2(d: dict) -> dict:
ip = d["ip"]
info = _http_get(f"http://{ip}/rpc/Shelly.GetDeviceInfo")
st = _http_get(f"http://{ip}/rpc/Shelly.GetStatus")
out: dict = {
"ip": ip,
"name": (info.get("name") if isinstance(info, dict) else None) or d["name"],
"gen": 2,
"kind": d["kind"],
"id": info.get("id") if isinstance(info, dict) else None,
}
if not isinstance(st, dict):
out["error"] = str(st)
return out
cover = st.get("cover:0") or {}
out["cover"] = {
"state": cover.get("state"),
"current_pos": cover.get("current_pos"),
"apower": cover.get("apower"),
"voltage": cover.get("voltage"),
"source": cover.get("source"),
}
wifi = st.get("wifi") or {}
out["wifi_rssi"] = wifi.get("rssi")
if d.get("motor_broken"):
out["warning"] = "Motor defekt — Bewegung gesperrt"
return out
def tool_status(args: dict) -> str:
resolved = _resolve(args.get("target") or args.get("device") or args.get("name"))
if isinstance(resolved, str):
return resolved
key, d = resolved
try:
data = _status_gen2(d) if d["gen"] == 2 else _status_gen1(d)
data["key"] = key
return json.dumps(data, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
return f"Fehler Status {d['name']} ({d['ip']}): {e}"
def _cover_gen1_roller(ip: str, action: str, position: int | None) -> str:
if action == "open":
url = f"http://{ip}/roller/0?go=open"
elif action == "close":
url = f"http://{ip}/roller/0?go=close"
elif action == "stop":
url = f"http://{ip}/roller/0?go=stop"
elif action == "position":
if position is None:
return "position erfordert Argument position (0-100)."
url = f"http://{ip}/roller/0?go=to_pos&roller_pos={int(position)}"
else:
return f"Unbekannte action {action!r}"
res = _http_get(url)
return json.dumps({"ok": True, "action": action, "result": res}, ensure_ascii=False)
def _cover_gen2(ip: str, action: str, position: int | None) -> str:
if action == "open":
method, params = "Cover.Open", {"id": 0}
elif action == "close":
method, params = "Cover.Close", {"id": 0}
elif action == "stop":
method, params = "Cover.Stop", {"id": 0}
elif action == "position":
if position is None:
return "position erfordert Argument position (0-100)."
method, params = "Cover.GoToPosition", {"id": 0, "pos": int(position)}
else:
return f"Unbekannte action {action!r}"
res = _http_post_json(f"http://{ip}/rpc", {"id": 1, "method": method, "params": params})
return json.dumps({"ok": True, "action": action, "result": res}, ensure_ascii=False)
def tool_cover(args: dict) -> str:
resolved = _resolve(args.get("target") or args.get("device") or args.get("name"))
if isinstance(resolved, str):
return resolved
key, d = resolved
if d["kind"] != "cover":
return (
f"{d['name']} ist kein Rollo (kind={d['kind']}). "
"Fuer Tor/Tuersummer: shelly_pulse. Fuer Sensoren: shelly_status."
)
if d.get("motor_broken"):
return (
f"BLOCKIERT: {d['name']} ({d['ip']}) — Motor defekt. "
"Keine open/close/stop/position. Nur Status via shelly_status."
)
action = str(args.get("action") or "").strip().lower()
if action in ("auf", "oeffnen", "öffnen"):
action = "open"
elif action in ("zu", "schliessen", "schließen"):
action = "close"
elif action in ("halt", "stopp"):
action = "stop"
elif action in ("pos", "goto", "to_pos"):
action = "position"
if action not in ("open", "close", "stop", "position"):
return "action muss open|close|stop|position sein."
pos = args.get("position")
if pos is not None:
try:
pos = int(pos)
except (TypeError, ValueError):
return "position muss 0-100 sein."
if not 0 <= pos <= 100:
return "position muss 0-100 sein."
try:
if d["gen"] == 2:
return _cover_gen2(d["ip"], action, pos)
return _cover_gen1_roller(d["ip"], action, pos)
except Exception as e: # noqa: BLE001
return f"Fehler shelly_cover {d['name']}: {e}"
def tool_pulse(args: dict) -> str:
if _is_cron():
return (
"BLOCKIERT: Garagentor/Haustuer-Impuls ist in Cron-Jobs verboten. "
"Nur interaktiv im Chat mit confirm=true."
)
resolved = _resolve(args.get("target") or args.get("device") or args.get("name"))
if isinstance(resolved, str):
return resolved
key, d = resolved
if d["kind"] != "pulse":
return f"{d['name']} ist kein Impuls-Geraet. Nutze shelly_cover fuer Rollos."
if not _truthy(args.get("confirm")):
return (
f"Sicherheit: Impuls fuer {d['name']} braucht confirm=true. "
"Ohne Bestaetigung keine Ausfuehrung."
)
ip = d["ip"]
try:
# Gen1 Shelly 1: toggle or on — devices use auto_off=1s
res = _http_get(f"http://{ip}/relay/0?turn=on")
return json.dumps(
{"ok": True, "device": d["name"], "ip": ip, "action": "pulse_on", "result": res},
ensure_ascii=False,
)
except Exception as e: # noqa: BLE001
return f"Fehler shelly_pulse {d['name']}: {e}"
TOOLS = [
{
"name": "shelly_list",
"description": (
"Listet alle bekannten Shellys (IP, Name, Typ, Aliase, Safety-Hinweise). "
"Kein Netzwerknoetig fuer die Inventarliste."
),
"inputSchema": {"type": "object", "properties": {}, "required": []},
},
{
"name": "shelly_status",
"description": (
"Live-Status eines Shellys (Rollo-Position, Relay, Sensor-Temps). "
"Ziel per Alias/IP/Name: kueche, computerraum, gaestezimmer, bad_unten, "
"garagentor, haustuer, garage_decke_a/b."
),
"inputSchema": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "Alias, Name oder IP des Geraets",
}
},
"required": ["target"],
},
},
{
"name": "shelly_cover",
"description": (
"Rollo steuern: action=open|close|stop|position. "
"Ziele: kueche, computerraum, gaestezimmer. "
"bad_unten ist BLOCKIERT (Motor defekt). "
"Cron-Jobs duerfen Rollos steuern."
),
"inputSchema": {
"type": "object",
"properties": {
"target": {"type": "string", "description": "Rollo-Alias"},
"action": {
"type": "string",
"description": "open|close|stop|position",
},
"position": {
"type": "number",
"description": "0-100 bei action=position (0=zu, 100=auf)",
},
},
"required": ["target", "action"],
},
},
{
"name": "shelly_pulse",
"description": (
"Impuls fuer Garagentor oder Haustuer-Tuersummer. "
"PFLICHT: confirm=true. In Cron-Jobs IMMER blockiert."
),
"inputSchema": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "garagentor oder haustuer",
},
"confirm": {
"type": "boolean",
"description": "Muss true sein zur Ausfuehrung",
},
},
"required": ["target", "confirm"],
},
},
]
HANDLERS = {
"shelly_list": tool_list,
"shelly_status": tool_status,
"shelly_cover": tool_cover,
"shelly_pulse": tool_pulse,
}
def handle(req):
method = req.get("method")
rid = req.get("id")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": rid,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "shelly", "version": "1.0.0"},
},
}
if method in ("notifications/initialized", "initialized"):
return None
if method == "tools/list":
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
if method == "tools/call":
name = req.get("params", {}).get("name")
args = req.get("params", {}).get("arguments", {}) or {}
handler = HANDLERS.get(name)
try:
text = handler(args) if handler else f"Unbekanntes Tool: {name}"
except Exception as e: # noqa: BLE001
text = f"Fehler: {e}"
return {
"jsonrpc": "2.0",
"id": rid,
"result": {"content": [{"type": "text", "text": text}]},
}
if method == "ping":
return {"jsonrpc": "2.0", "id": rid, "result": {}}
return {
"jsonrpc": "2.0",
"id": rid,
"error": {"code": -32601, "message": f"Unknown: {method}"},
}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
continue
try:
resp = handle(req)
except Exception as e: # noqa: BLE001
resp = {
"jsonrpc": "2.0",
"id": req.get("id"),
"error": {"code": -32603, "message": str(e)},
}
if resp is not None:
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()