"""Optional read-only calendar and state providers, no vendor dependency in templates.""" import json import re import time from datetime import UTC, datetime, timedelta from urllib.parse import quote, urlsplit import requests from pydantic import Field from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import StrictModel from pigway_frame_flow.storage.database import encode class ExternalProviderConfig(StrictModel): kind: str = Field(pattern=r"^(google-calendar|ics|home-assistant|http-json)$") name: str = Field(min_length=1, max_length=120) endpoint: str = Field(max_length=2048) enabled: bool = True allow_insecure_lan: bool = False entities: list[str] = Field(default_factory=list, max_length=100) class ExternalProviders: def __init__(self, service): self.service = service self.cache = {} def list(self): return [ dict(json.loads(row["payload"]), id=row["key"][9:]) for row in self.service.db.all( "SELECT key,payload FROM app_config WHERE key LIKE 'provider:%'" ) ] def config(self, id): row = self.service.db.one( "SELECT payload FROM app_config WHERE key=?", ("provider:" + id,) ) if not row: raise FlowError(E.NOT_FOUND, "Provider not found", 404) return ExternalProviderConfig.model_validate_json(row["payload"]) def save(self, id, config): if not re.fullmatch(r"[a-z0-9-]{1,60}", id): raise FlowError(E.INVALID_REQUEST, "Invalid provider ID") if config.kind != "google-calendar": parsed = urlsplit(config.endpoint) if ( parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.fragment ): raise FlowError( E.INVALID_REQUEST, "Provider needs an HTTP(S) endpoint without credentials", ) if not config.allow_insecure_lan and ( parsed.scheme != "https" or parsed.hostname not in self.service.settings.image_url_hosts ): raise FlowError( E.FORBIDDEN, "Provider host must be allowed; LAN access requires explicit opt-in", 403, ) self.service.db.execute( "INSERT OR REPLACE INTO app_config VALUES(?,?)", ("provider:" + id, encode(config.model_dump())), ) self.cache.pop(id, None) return dict(config.model_dump(), id=id) def get(self, id): config = self.config(id) if not config.enabled: raise FlowError(E.CONFLICT, "Provider disabled", 409) if id in self.cache and time.monotonic() < self.cache[id][0]: return self.cache[id][1] secretfile = self.service.settings.config_dir / ("provider-" + id + ".secret") token = secretfile.read_text().strip() if secretfile.exists() else "" if token: self.service.settings.additional_secrets.append(token) headers = {"Authorization": "Bearer " + token} if token else {} if config.kind == "google-calendar": if not token: raise FlowError( E.CONFIG_REQUIRED, "Google Calendar requires an authorized OAuth token", 409, ) start = datetime.now(UTC) from urllib.parse import urlencode url = ( "https://www.googleapis.com/calendar/v3/calendars/" + quote(config.endpoint, safe="") + "/events?" + urlencode( { "timeMin": start.isoformat(), "timeMax": (start + timedelta(days=7)).isoformat(), "singleEvents": "true", "orderBy": "startTime", "maxResults": 100, } ) ) if "www.googleapis.com" not in self.service.settings.image_url_hosts: self.service.settings.image_url_hosts.append("www.googleapis.com") else: url = config.endpoint.rstrip("/") + ( "/api/states" if config.kind == "home-assistant" else "" ) try: if config.allow_insecure_lan: # Endpoint is an explicitly administrator-approved, fixed LAN destination. with requests.get( url, headers=headers, timeout=10, allow_redirects=False, stream=True ) as response: response.raise_for_status() parts = [] total = 0 for part in response.iter_content(65536): total += len(part) if total > 2 * 1024 * 1024: raise ValueError() parts.append(part) raw = b"".join(parts) else: raw = self.service.assets.fetch_url( url, headers=headers, limit=2 * 1024 * 1024 ) result: dict if config.kind == "ics": from pigway_frame_flow.providers.ical import calendar_events result = calendar_events(raw) elif config.kind == "google-calendar": result = { "events": [ { "title": x.get("summary", "Untitled"), "start": x.get("start", {}).get( "dateTime", x.get("start", {}).get("date") ), } for x in json.loads(raw).get("items", []) ] } elif config.kind == "home-assistant": result = { "events": [ x["entity_id"] + ": " + str(x["state"]) for x in json.loads(raw) if not config.entities or x["entity_id"] in config.entities ] } else: result = json.loads(raw) if not isinstance(result, dict): raise ValueError() result = self.service.settings.sanitize(result) self.cache[id] = (time.monotonic() + 300, result) return result except FlowError: raise except Exception: raise FlowError( E.CLOUD_UNAVAILABLE, "External provider unavailable; check connection and credentials", 502, ) from None