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 resp.ok: return resp # Try REST API DELETE as fallback (works for some integrations) from urllib.parse import quote encoded_uid = quote(uid, safe="") rest_resp = http_requests.delete( f"{base}/api/calendars/{entity_id}/{encoded_uid}", headers={"Authorization": f"Bearer {token}"}, timeout=15, verify=False, ) if rest_resp.ok: return rest_resp # Both failed – build a helpful error message try: detail = resp.json().get("message", resp.text[:500]) except Exception: detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" if resp.status_code == 400: detail = f"{detail} (Diese HA-Kalender-Integration unterstützt kein Löschen — z.B. Google-Calendar via HA ist read-only für Updates/Löschen)" raise Exception(f"HA delete_event ({resp.status_code}): {detail}") 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}")