Files
pigway-frame-flow/pigway_frame_flow/providers/external.py
T

197 lines
7.7 KiB
Python

"""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":
events = []
text = (
raw.decode("utf-8-sig").replace("\r\n ", "").replace("\r\n", "\n")
)
for block in text.split("BEGIN:VEVENT")[1:]:
block = block.split("END:VEVENT")[0]
values = {
line.split(":", 1)[0].split(";")[0]: line.split(":", 1)[1]
for line in block.splitlines()
if ":" in line
}
if "RRULE" in values:
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Recurring ICS events require a calendar provider with expanded instances",
422,
)
if values.get("SUMMARY"):
events.append(values["SUMMARY"].replace("\\n", " "))
result = {"events": events[:100]}
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