Files
Calendarr/backend/routers/homeassistant_router.py
Scarriffle b803d4bf4c fix: HA Datetime-Format mit Timezone, leere Strings filtern, Debug-Logs
- _ha_format_dt: Parst ISO-Datetime zu datetime-Objekt, emittiert
  ohne Millisekunden, MIT Timezone-Offset. Vorher landeten Termine
  am falschen Datum, weil das Frontend UTC schickt aber wir die
  Timezone gestrippt haben → HA hat als lokale Zeit interpretiert
- Leere Strings werden nicht mehr in den Body aufgenommen (HA
  Validator könnte diese ablehnen)
- Logging in create/delete/update für besseres Debugging der HA-Calls
2026-04-29 19:50:52 +02:00

673 lines
22 KiB
Python

import logging
import secrets
import time
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode
import requests as http_requests
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
HA_DEFAULT_COLOR = "#03a9f4"
# In-memory store for pending OAuth states (short-lived, ~10 min TTL)
_pending_oauth: dict[str, dict] = {}
def _cleanup_pending():
now = time.time()
for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]:
_pending_oauth.pop(k, None)
# ── Auth helpers ──────────────────────────────────────────
def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple:
"""Refresh grant → (access_token, expires_in)"""
resp = http_requests.post(
f"{url.rstrip('/')}/auth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
},
timeout=10,
verify=False,
)
resp.raise_for_status()
data = resp.json()
return data["access_token"], data.get("expires_in", 1800)
def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
"""Return a valid access token, refreshing if necessary."""
if account.auth_method != "oauth":
return account.token # Long-Lived Token läuft nicht ab
now = datetime.now(timezone.utc)
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
return account.token
# Needs refresh
try:
access_token, expires_in = _ha_refresh(
account.url, account.refresh_token, account.client_id or ""
)
except Exception as exc:
logger.error("HA token refresh failed for %s: %s", account.name, exc)
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
account.token = access_token
account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
db.commit()
return access_token
# ── HA API helpers ────────────────────────────────────────
def _ha_get_calendars(url: str, token: str) -> list:
try:
resp = http_requests.get(
f"{url.rstrip('/')}/api/calendars",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
verify=False,
)
resp.raise_for_status()
return resp.json()
except http_requests.exceptions.ConnectionError:
raise HTTPException(503, "Home Assistant nicht erreichbar")
except http_requests.exceptions.Timeout:
raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
except http_requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 401:
raise HTTPException(400, "Ungültiger Access Token")
raise HTTPException(502, f"Home Assistant Fehler: {e}")
def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end_dt: datetime) -> list:
try:
resp = http_requests.get(
f"{url.rstrip('/')}/api/calendars/{entity_id}",
headers={"Authorization": f"Bearer {token}"},
params={"start": start_dt.isoformat(), "end": end_dt.isoformat()},
timeout=15,
verify=False,
)
resp.raise_for_status()
return resp.json()
except http_requests.exceptions.ConnectionError:
raise http_requests.exceptions.ConnectionError(f"HA nicht erreichbar für {entity_id}")
except http_requests.exceptions.Timeout:
raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}")
def _ha_format_dt(s: str) -> str:
"""Convert ISO datetime to HA format with timezone, no milliseconds.
HA's cv.datetime accepts ISO 8601. Keep the timezone offset so HA
interprets the time correctly regardless of HA's local timezone.
"""
# Frontend sends "2026-05-07T15:00:00.000Z"; normalize Z to +00:00
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
# Strip fractional seconds if fromisoformat can't handle them
import re
s2 = re.sub(r"\.\d+", "", s)
dt = datetime.fromisoformat(s2)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds")
def _ha_build_event_body(entity_id: str, data: dict) -> dict:
"""Build a service-call body for create_event / update_event."""
body = {"entity_id": entity_id}
if data.get("title"):
body["summary"] = data["title"]
if data.get("description"):
body["description"] = data["description"]
if data.get("location"):
body["location"] = data["location"]
if data.get("start") and data.get("end"):
if data.get("allDay"):
body["start_date"] = data["start"][:10]
body["end_date"] = data["end"][:10]
else:
body["start_date_time"] = _ha_format_dt(data["start"])
body["end_date_time"] = _ha_format_dt(data["end"])
return body
def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
"""Create a new event via HA calendar.create_event service."""
base = url.rstrip("/")
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
body = _ha_build_event_body(entity_id, data)
logger.info("HA create_event body: %s", body)
resp = http_requests.post(
f"{base}/api/services/calendar/create_event",
headers=headers, json=body, timeout=15, verify=False,
)
if not resp.ok:
try:
detail = resp.json().get("message", resp.text[:500])
except Exception:
detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
raise Exception(f"HA create_event ({resp.status_code}): {detail}")
return resp
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
"""Update via update_event service, fallback to delete+create."""
base = url.rstrip("/")
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
# Try update_event service (HA 2024.6+)
body = _ha_build_event_body(entity_id, 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):
"""Delete an event via HA service call API (calendar.delete_event)."""
base = url.rstrip("/")
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
body = {"entity_id": entity_id, "uid": uid}
logger.info("HA delete_event body: %s", body)
resp = http_requests.post(
f"{base}/api/services/calendar/delete_event",
headers=headers, json=body, timeout=15, verify=False,
)
if not resp.ok:
try:
detail = resp.json().get("message", resp.text[:500])
except Exception:
detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
raise Exception(f"HA delete_event ({resp.status_code}): {detail}")
return resp
def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict:
start = ev.get("start", {})
end = ev.get("end", {})
all_day = "date" in start and "dateTime" not in start
return {
"id": ev.get("uid") or f"ha-{cal_db_id}-{ev.get('summary', '')}",
"url": f"homeassistant://{cal_db_id}/{ev.get('uid', '')}",
"title": ev.get("summary", "(Kein Titel)"),
"start": start.get("dateTime") or start.get("date", ""),
"end": end.get("dateTime") or end.get("date", ""),
"allDay": all_day,
"location": ev.get("location", ""),
"description": ev.get("description", ""),
"color": None,
"calendar_id": f"homeassistant-{cal_db_id}",
"calendar_name": cal_name,
"calendarColor": cal_color,
"source": "homeassistant",
}
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
all_events = []
try:
token = _get_valid_token(account, db)
except Exception as exc:
logger.error("HA token error for %s: %s", account.name, exc)
raise
for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden:
continue
try:
raw = _ha_get_events(account.url, token, cal.entity_id, start_dt, end_dt)
color = cal.color or HA_DEFAULT_COLOR
for ev in raw:
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
except Exception as exc:
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
return all_events
# ── Serialization ─────────────────────────────────────────
def _account_dict(a: models.HomeAssistantAccount) -> dict:
return {
"id": a.id,
"name": a.name,
"url": a.url,
"auth_method": a.auth_method or "token",
"calendars": [
{
"id": c.id,
"name": c.name,
"entity_id": c.entity_id,
"color": c.color or HA_DEFAULT_COLOR,
"enabled": c.enabled,
"sidebar_hidden": bool(c.sidebar_hidden),
}
for c in a.calendars
],
}
# ── Pydantic models ───────────────────────────────────────
class HAAccountCreate(BaseModel):
name: str
url: str
token: str
class HAOAuthStart(BaseModel):
name: str
url: str
client_id: str
redirect_uri: str
class HACalendarUpdate(BaseModel):
enabled: Optional[bool] = None
color: Optional[str] = None
name: Optional[str] = None
sidebar_hidden: Optional[bool] = None
# ── Endpoints ─────────────────────────────────────────────
@router.get("/accounts")
def list_accounts(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.HomeAssistantAccount)
.filter(models.HomeAssistantAccount.user_id == current_user.id)
.all()
)
return [_account_dict(a) for a in accounts]
@router.post("/accounts")
def add_account(
data: HAAccountCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Create a HA account from a Long-Lived Access Token."""
remote_cals = _ha_get_calendars(data.url, data.token)
account = models.HomeAssistantAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
token=data.token,
auth_method="token",
refresh_token=None,
token_expiry=None,
)
db.add(account)
db.flush()
for cal in remote_cals:
entity_id = cal.get("entity_id", "")
if not entity_id:
continue
db.add(models.HomeAssistantCalendar(
account_id=account.id,
entity_id=entity_id,
name=cal.get("name") or entity_id,
color=None,
enabled=True,
))
db.commit()
db.refresh(account)
return _account_dict(account)
@router.post("/auth-url")
def oauth_start(
data: HAOAuthStart,
current_user: models.User = Depends(get_current_user),
):
"""Start the OAuth flow: store pending state, return HA authorization URL."""
_cleanup_pending()
state_token = secrets.token_urlsafe(32)
_pending_oauth[state_token] = {
"user_id": current_user.id,
"ha_url": data.url.rstrip('/'),
"name": data.name,
"client_id": data.client_id,
"redirect_uri": data.redirect_uri,
"expires": time.time() + 600,
}
params = {
"client_id": data.client_id,
"redirect_uri": data.redirect_uri,
"state": state_token,
"response_type": "code",
}
return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"}
@router.get("/callback")
def oauth_callback(
request: Request,
code: str = Query(""),
state: str = Query(""),
error: str = Query(""),
db: Session = Depends(get_db),
):
"""Callback from Home Assistant after user authorization."""
if error or not code:
return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302)
pending = _pending_oauth.pop(state, None)
if not pending or pending["expires"] < time.time():
return RedirectResponse(url="/?ha_error=state_expired", status_code=302)
ha_url = pending["ha_url"]
client_id = pending["client_id"]
# Exchange code for tokens
try:
resp = http_requests.post(
f"{ha_url}/auth/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
},
timeout=15,
verify=False,
)
except Exception as exc:
logger.error("HA token exchange connection error: %s", exc)
return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302)
if resp.status_code != 200:
logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text)
return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302)
tokens = resp.json()
access_token = tokens["access_token"]
refresh_token = tokens.get("refresh_token", "")
expires_in = tokens.get("expires_in", 1800)
now = datetime.now(timezone.utc)
try:
remote_cals = _ha_get_calendars(ha_url, access_token)
except HTTPException as exc:
logger.error("HA calendar fetch failed after OAuth: %s", exc.detail)
return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302)
account = models.HomeAssistantAccount(
user_id=pending["user_id"],
name=pending["name"],
url=ha_url,
token=access_token,
auth_method="oauth",
refresh_token=refresh_token,
token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc),
client_id=client_id,
)
db.add(account)
db.flush()
for cal in remote_cals:
entity_id = cal.get("entity_id", "")
if not entity_id:
continue
db.add(models.HomeAssistantCalendar(
account_id=account.id,
entity_id=entity_id,
name=cal.get("name") or entity_id,
color=None,
enabled=True,
))
db.commit()
return RedirectResponse(url="/?ha_connected=1", status_code=302)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.HomeAssistantAccount)
.filter(
models.HomeAssistantAccount.id == account_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not acc:
raise HTTPException(404, "Account not found")
db.delete(acc)
db.commit()
return {"ok": True}
@router.post("/accounts/{account_id}/sync")
def sync_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.HomeAssistantAccount)
.filter(
models.HomeAssistantAccount.id == account_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not acc:
raise HTTPException(404, "Account not found")
token = _get_valid_token(acc, db)
remote_cals = _ha_get_calendars(acc.url, token)
existing = {c.entity_id: c for c in acc.calendars}
for cal in remote_cals:
entity_id = cal.get("entity_id", "")
if not entity_id:
continue
if entity_id not in existing:
db.add(models.HomeAssistantCalendar(
account_id=acc.id,
entity_id=entity_id,
name=cal.get("name") or entity_id,
color=None,
enabled=True,
))
else:
existing[entity_id].name = cal.get("name") or entity_id
db.commit()
db.refresh(acc)
return _account_dict(acc)
@router.put("/calendars/{calendar_id}")
def update_calendar(
calendar_id: int,
data: HACalendarUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if data.enabled is not None:
cal.enabled = data.enabled
if data.color is not None:
cal.color = data.color
if data.name is not None:
cal.name = data.name
if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden
db.commit()
return {"ok": True}
# ── Event CRUD ───────────────────────────────────────────
class HAEventUpdate(BaseModel):
title: Optional[str] = None
start: Optional[str] = None
end: Optional[str] = None
allDay: Optional[bool] = None
location: Optional[str] = None
description: Optional[str] = None
class HAEventCreate(BaseModel):
calendar_id: int
title: str
start: str
end: str
allDay: bool = False
location: Optional[str] = None
description: Optional[str] = None
@router.post("/events")
def create_event(
data: HAEventCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == data.calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_create_event(
account.url, token, cal.entity_id,
data.model_dump(exclude_none=True),
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event create failed: {exc}")
@router.put("/events/{calendar_id}/{uid}")
def update_event(
calendar_id: int,
uid: str,
data: HAEventUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_update_event(
account.url, token, cal.entity_id, uid,
data.model_dump(exclude_none=True),
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event update failed: {exc}")
@router.delete("/events/{calendar_id}/{uid}")
def delete_event(
calendar_id: int,
uid: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_delete_event(account.url, token, cal.entity_id, uid)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event delete failed: {exc}")