feat: HA-Events über WebSocket API (calendar/event/delete und update)

Manche HA-Integrationen registrieren nur den WebSocket-Handler, keinen
Service-Call. Die HA-Web-UI nutzt deshalb den WebSocket-Pfad. Calendarr
macht das jetzt auch:
- _ha_ws_call: minimaler WebSocket-Client für eine einzelne Command
- create: erst WS, dann Service-Call als Fallback
- update: nur WS (Service-Call existiert oft nicht)
- delete: nur WS (Service-Call existiert oft nicht)
Neue Dependency: websocket-client==1.8.0
This commit is contained in:
Scarriffle
2026-04-29 20:01:12 +02:00
parent 64f8b901dd
commit e99f91dcf3
2 changed files with 96 additions and 79 deletions

View File

@@ -1,11 +1,14 @@
import json
import logging import logging
import secrets import secrets
import ssl
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import requests as http_requests import requests as http_requests
import websocket as ws_client
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -149,8 +152,85 @@ def _ha_build_event_body(entity_id: str, data: dict) -> dict:
return body return body
def _ha_ws_call(url: str, token: str, command: dict) -> dict:
"""Send a single WebSocket command to HA and return the result.
Used for calendar/event/delete and calendar/event/update which are
only exposed via WebSocket, not as service calls.
"""
# Convert http(s):// → ws(s)://
if url.startswith("https://"):
ws_url = "wss://" + url[len("https://"):].rstrip("/") + "/api/websocket"
sslopt = {"cert_reqs": ssl.CERT_NONE}
elif url.startswith("http://"):
ws_url = "ws://" + url[len("http://"):].rstrip("/") + "/api/websocket"
sslopt = None
else:
raise Exception(f"Ungültige HA-URL: {url}")
sock = ws_client.create_connection(ws_url, timeout=15, sslopt=sslopt)
try:
# 1. auth_required
msg = json.loads(sock.recv())
if msg.get("type") != "auth_required":
raise Exception(f"Unerwartete WS-Antwort: {msg}")
# 2. send auth
sock.send(json.dumps({"type": "auth", "access_token": token}))
msg = json.loads(sock.recv())
if msg.get("type") != "auth_ok":
raise Exception(f"WS Auth fehlgeschlagen: {msg.get('message', msg)}")
# 3. send command
cmd = {"id": 1, **command}
logger.info("HA WS command: %s", cmd)
sock.send(json.dumps(cmd))
# 4. receive result (might receive other messages first, ignore them)
for _ in range(10):
msg = json.loads(sock.recv())
if msg.get("id") == 1:
if msg.get("success"):
return msg.get("result", {})
err = msg.get("error", {})
raise Exception(f"{err.get('code', 'error')}: {err.get('message', 'Unbekannter Fehler')}")
raise Exception("Keine Antwort von HA WebSocket")
finally:
try:
sock.close()
except Exception:
pass
def _ha_ws_event_payload(data: dict) -> dict:
"""Build the 'event' payload for calendar/event/create|update WS commands."""
payload = {}
if data.get("title"):
payload["summary"] = data["title"]
if data.get("description"):
payload["description"] = data["description"]
if data.get("location"):
payload["location"] = data["location"]
if data.get("start") and data.get("end"):
if data.get("allDay"):
payload["dtstart"] = data["start"][:10]
payload["dtend"] = data["end"][:10]
else:
payload["dtstart"] = _ha_format_dt(data["start"])
payload["dtend"] = _ha_format_dt(data["end"])
return payload
def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict: def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
"""Create a new event via HA calendar.create_event service.""" """Create a new event. Tries WebSocket command first, falls back to service call."""
# Try WebSocket calendar/event/create
try:
return _ha_ws_call(url, token, {
"type": "calendar/event/create",
"entity_id": entity_id,
"event": _ha_ws_event_payload(data),
})
except Exception as exc:
logger.info("HA WS create failed (%s), falling back to service call", exc)
# Fallback: service call
base = url.rstrip("/") base = url.rstrip("/")
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
body = _ha_build_event_body(entity_id, data) body = _ha_build_event_body(entity_id, data)
@@ -165,90 +245,26 @@ def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
except Exception: except Exception:
detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
raise Exception(f"HA create_event ({resp.status_code}): {detail}") raise Exception(f"HA create_event ({resp.status_code}): {detail}")
return resp return {}
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict): def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
"""Update via update_event service, fallback to delete+create.""" """Update an event via WebSocket command (the only reliable path)."""
base = url.rstrip("/") return _ha_ws_call(url, token, {
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} "type": "calendar/event/update",
"entity_id": entity_id,
# Try update_event service (HA 2024.6+) "uid": uid,
body = _ha_build_event_body(entity_id, data) "event": _ha_ws_event_payload(data),
body["uid"] = uid })
resp = http_requests.post(
f"{base}/api/services/calendar/update_event",
headers=headers, json=body, timeout=15, verify=False,
)
if resp.ok:
return resp
logger.info("HA update_event not available (%s), falling back to delete+create", resp.status_code)
# Fallback: delete old event, then create new one
del_resp = http_requests.post(
f"{base}/api/services/calendar/delete_event",
headers=headers,
json={"entity_id": entity_id, "uid": uid},
timeout=15, verify=False,
)
if not del_resp.ok:
logger.warning("HA delete_event failed (%s): %s", del_resp.status_code, del_resp.text[:200])
# If delete also fails, try create anyway (might duplicate)
create_body = _ha_build_event_body(entity_id, data)
create_resp = http_requests.post(
f"{base}/api/services/calendar/create_event",
headers=headers, json=create_body, timeout=15, verify=False,
)
if not create_resp.ok:
try:
detail = create_resp.json().get("message", create_resp.text[:500])
except Exception:
detail = create_resp.text[:500] if create_resp.text else str(create_resp.status_code)
raise Exception(f"HA create_event ({create_resp.status_code}): {detail}")
return create_resp
def _ha_delete_event(url: str, token: str, entity_id: str, uid: str): def _ha_delete_event(url: str, token: str, entity_id: str, uid: str):
"""Delete an event via HA service call API (calendar.delete_event). """Delete an event via WebSocket command (the only reliable path)."""
return _ha_ws_call(url, token, {
Tries multiple body formats since HA's service-call schema accepts "type": "calendar/event/delete",
different shapes depending on version. "entity_id": entity_id,
""" "uid": uid,
base = url.rstrip("/") })
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
service_url = f"{base}/api/services/calendar/delete_event"
# Format 1: entity_id flat in body (most common)
bodies = [
{"entity_id": entity_id, "uid": uid},
# Format 2: entity_id as list
{"entity_id": [entity_id], "uid": uid},
# Format 3: target wrapper
{"target": {"entity_id": entity_id}, "uid": uid},
]
last_resp = None
for i, body in enumerate(bodies):
logger.info("HA delete_event try %d body: %s", i + 1, body)
resp = http_requests.post(
service_url, headers=headers, json=body, timeout=15, verify=False,
)
if resp.ok:
return resp
last_resp = resp
logger.warning("HA delete_event format %d failed (%s): %s",
i + 1, resp.status_code, resp.text[:200])
# All failed build a helpful error message
try:
detail = last_resp.json().get("message", last_resp.text[:500])
except Exception:
detail = last_resp.text[:500] if last_resp.text else f"HTTP {last_resp.status_code}"
if last_resp.status_code == 400:
detail = f"{detail} — Vermutlich unterstützt deine HA-Kalender-Integration kein Löschen. Bitte in HA Developer Tools → Services testen mit calendar.delete_event"
raise Exception(f"HA delete_event ({last_resp.status_code}): {detail}")
def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict: def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict:

View File

@@ -11,3 +11,4 @@ pyotp==2.9.0
qrcode[pil]==8.0 qrcode[pil]==8.0
Pillow==11.0.0 Pillow==11.0.0
python-dateutil==2.9.0 python-dateutil==2.9.0
websocket-client==1.8.0