diff --git a/backend/main.py b/backend/main.py index 534665d..ef673d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from sqlalchemy import text sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -76,6 +76,7 @@ app.include_router(profile_router.router, prefix="/api/profile", tags=["profile" app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) +app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) FRONTEND_DIR = Path(__file__).parent.parent / "frontend" app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") diff --git a/backend/models.py b/backend/models.py index 38cf548..4f85b31 100644 --- a/backend/models.py +++ b/backend/models.py @@ -30,6 +30,9 @@ class User(Base): google_accounts = relationship( "GoogleAccount", back_populates="user", cascade="all, delete-orphan" ) + homeassistant_accounts = relationship( + "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" + ) class CalDAVAccount(Base): @@ -176,3 +179,32 @@ class GoogleCalendar(Base): sidebar_hidden = Column(Boolean, default=False) account = relationship("GoogleAccount", back_populates="calendars") + + +class HomeAssistantAccount(Base): + __tablename__ = "homeassistant_accounts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + token = Column(Text, nullable=False) + + user = relationship("User", back_populates="homeassistant_accounts") + calendars = relationship( + "HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan" + ) + + +class HomeAssistantCalendar(Base): + __tablename__ = "homeassistant_calendars" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False) + entity_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + color = Column(String(7), nullable=True) + enabled = Column(Boolean, default=True) + sidebar_hidden = Column(Boolean, default=False) + + account = relationship("HomeAssistantAccount", back_populates="calendars") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 1aa38e4..d555487 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -266,7 +266,7 @@ def get_events( for account in accounts: for calendar in account.calendars: - if not calendar.enabled: + if not calendar.enabled or calendar.sidebar_hidden: continue try: events = caldav_client.fetch_events( @@ -355,6 +355,19 @@ def get_events( logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) google_errors.append({"email": g_acc.email}) + # ── Home Assistant events ───────────────────────────── + from routers.homeassistant_router import get_ha_events + ha_accounts = ( + db.query(models.HomeAssistantAccount) + .filter(models.HomeAssistantAccount.user_id == current_user.id) + .all() + ) + for ha_acc in ha_accounts: + try: + all_events.extend(get_ha_events(ha_acc, start_dt, end_dt)) + except Exception as exc: + logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) + return {"events": all_events, "errors": google_errors} diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py index dbcf9c8..510090b 100644 --- a/backend/routers/google_router.py +++ b/backend/routers/google_router.py @@ -388,7 +388,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: all_events = [] for gcal in account.calendars: - if not gcal.enabled: + if not gcal.enabled or gcal.sidebar_hidden: continue if _is_system_calendar(gcal.cal_id): continue diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py new file mode 100644 index 0000000..3bffdf2 --- /dev/null +++ b/backend/routers/homeassistant_router.py @@ -0,0 +1,268 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +import requests as http_requests +from fastapi import APIRouter, Depends, HTTPException +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" + + +# ── 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(401, "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 _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) -> list: + all_events = [] + for cal in account.calendars: + if not cal.enabled or cal.sidebar_hidden: + continue + try: + raw = _ha_get_events(account.url, account.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, + "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 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), +): + 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, + ) + 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.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") + + remote_cals = _ha_get_calendars(acc.url, acc.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} diff --git a/frontend/index.html b/frontend/index.html index 8b759f3..8497872 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -165,6 +165,7 @@ + @@ -399,6 +400,35 @@ + +
+ diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 5e924d1..b1fa8d5 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -32,6 +32,7 @@ let state = { localCalendars: [], icalSubscriptions: [], googleAccounts: [], + haAccounts: [], settings: {}, dimPast: false, editingEvent: null, // null = new event @@ -40,12 +41,13 @@ let state = { // ── Public init ─────────────────────────────────────────── export async function initCalendar() { - const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts] = await Promise.all([ + const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([ api.get('/settings/'), api.get('/caldav/accounts'), api.get('/local/calendars').catch(() => []), api.get('/ical/subscriptions').catch(() => []), api.get('/google/accounts').catch(() => []), + api.get('/homeassistant/accounts').catch(() => []), ]); state.settings = settings; @@ -53,6 +55,7 @@ export async function initCalendar() { state.localCalendars = localCalendars; state.icalSubscriptions = icalSubscriptions; state.googleAccounts = googleAccounts; + state.haAccounts = haAccounts; state.currentView = settings.default_view || 'month'; state.dimPast = settings.dim_past_events; weekStartDay = settings.week_start_day || 'monday'; @@ -69,6 +72,7 @@ export async function initCalendar() { bindAccountModal(); bindLocalCalModal(); bindICalSubModal(); + bindHAAccountModal(); bindSettingsModal(); bindProfileModal(); } @@ -449,6 +453,25 @@ function renderCalendarList() { }).join(''); } + // ── Home Assistant accounts ─────────────────────────── + if (state.haAccounts.length) { + html += state.haAccounts.map(acc => { + const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); + if (!visibleCals.length) return `