SmartestHome/trash-calendar/sync.py

182 lines
7.5 KiB
Python

"""trash-calendar — mirrors the household's trash/recycling collection dates into
the same Nextcloud calendar everything else already uses, from
docs/project-plan.md Phase 19.
THE PROBLEM: Kennelbach's own Abfallkalender (kennelbach.at) and Vorarlberg's
Umweltverband (umweltv.at) both publish collection dates as a **personal ICS feed**
keyed to your street/house number — real, live infrastructure, not something this
repo re-implements. What's missing is getting those dates into the SAME calendar the
household already looks at (Nextcloud, via `digest-engine/ingest/caldav.py`'s
existing read path) instead of a second place nobody checks.
Oneshot script + systemd timer (daily), not an always-on service — same shape as
`digest-engine/run.py`, and deliberately not folded into digest-engine itself: this
writes to the calendar, digest-engine's own `ingest/caldav.py` is READ-ONLY by
explicit invariant (see that file's docstring), and mixing a write path into a
component whose one documented guarantee is "never mutates" is exactly the kind of
scope creep worth a separate, smaller tool instead.
CREDENTIALS: reuses digest-engine's own CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD/
CALDAV_VERIFY_TLS names and the same "Nextcloud app password, not the account
password" reasoning (see `digest-engine/ingest/caldav.py`'s docstring) — one
Nextcloud app password, shared by both the read and write paths, not two separate
credentials to manage. `CALDAV_TARGET_CALENDAR` is new: which calendar (by display
name) this writes into — unlike the read path, which can read several, this writes
to exactly one, on purpose (never guess which of several calendars a light
household chore belongs in).
OWNERSHIP INVARIANT: every event this script creates gets a UID prefixed
`smartesthome-trash-`, deterministic from the source feed's own event content. It
only ever creates events under that prefix and only ever checks for their existence
before creating — it never reads, modifies, or deletes anything else in the target
calendar. A no-op re-run (nothing new in the source feed) touches nothing.
"""
from __future__ import annotations
import hashlib
import logging
import os
import sys
import urllib.request
from datetime import date, datetime, timedelta, timezone
LOG = logging.getLogger("trash-calendar")
UID_PREFIX = "smartesthome-trash-"
DEFAULT_LOOKAHEAD_DAYS = 60
def _stable_uid(summary: str, start: date) -> str:
digest = hashlib.sha1(f"{summary}|{start.isoformat()}".encode("utf-8")).hexdigest()[:16]
return f"{UID_PREFIX}{digest}@smartesthome"
def fetch_source_events(ics_url: str, lookahead_days: int) -> list[dict]:
"""Downloads and parses the household's personal collection-date ICS feed.
Degrades to an empty list (never raises past this point) if the feed is
unreachable or malformed — a missed sync run just means tomorrow's run tries
again, same "degrade, don't blank" rule as every renderer in this project.
"""
from icalendar import Calendar as ICalendar # see module docstring: caldav.py's identical import-inside-function reasoning
try:
req = urllib.request.Request(ics_url, headers={"User-Agent": "smartesthome-trash-calendar/1"})
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
except Exception:
LOG.warning("trash-calendar: could not fetch WASTE_ICS_URL", exc_info=True)
return []
today = date.today()
horizon = today + timedelta(days=lookahead_days)
events = []
try:
for component in ICalendar.from_ical(raw).walk("VEVENT"):
dtstart = component.get("dtstart")
if dtstart is None:
continue
start = dtstart.dt
start_date = start.date() if isinstance(start, datetime) else start
if not (today <= start_date <= horizon):
continue
summary = str(component.get("summary") or "Müllabfuhr").strip()
events.append({"summary": summary, "start": start_date})
except Exception:
LOG.warning("trash-calendar: could not parse WASTE_ICS_URL as iCalendar", exc_info=True)
return []
LOG.info("trash-calendar: %d collection date(s) in the next %d days", len(events), lookahead_days)
return events
def sync_to_caldav(events: list[dict]) -> None:
url = os.environ.get("CALDAV_URL", "").strip()
username = os.environ.get("CALDAV_USERNAME", "").strip()
password = os.environ.get("CALDAV_PASSWORD", "")
target_name = os.environ.get("CALDAV_TARGET_CALENDAR", "").strip()
if not (url and username and password and target_name):
LOG.error(
"trash-calendar: CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD/CALDAV_TARGET_CALENDAR "
"must all be set — see trash-calendar.env.example"
)
return
verify = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true"
if not verify:
LOG.warning("trash-calendar: CALDAV_VERIFY_TLS is false, the app password is sent over an unverified session")
import caldav as caldav_lib
with caldav_lib.DAVClient(url=url, username=username, password=password, ssl_verify_cert=verify) as client:
target = None
for calendar in client.principal().calendars():
try:
name = str(calendar.name or "")
except Exception:
continue
if name == target_name:
target = calendar
break
if target is None:
LOG.error("trash-calendar: no calendar named %r found on this Nextcloud account", target_name)
return
created = 0
for event in events:
uid = _stable_uid(event["summary"], event["start"])
try:
target.event_by_uid(uid)
continue # already exists — this script never updates, only creates (see module docstring)
except Exception:
pass # not found — fall through and create it
ics = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//SmartestHome//trash-calendar//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"DTSTAMP:{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTSTART;VALUE=DATE:{event['start'].strftime('%Y%m%d')}\r\n"
f"SUMMARY:{event['summary']}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
try:
target.save_event(ics)
created += 1
except Exception:
LOG.warning("trash-calendar: could not create event for %s on %s", event["summary"], event["start"], exc_info=True)
LOG.info("trash-calendar: created %d new event(s) in %r (existing ones left untouched)", created, target_name)
def main() -> int:
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
ics_url = os.environ.get("WASTE_ICS_URL", "").strip()
if not ics_url:
LOG.error("trash-calendar: WASTE_ICS_URL is not set — see trash-calendar.env.example and README.md")
return 1
lookahead_days = int(os.environ.get("WASTE_LOOKAHEAD_DAYS") or DEFAULT_LOOKAHEAD_DAYS)
events = fetch_source_events(ics_url, lookahead_days)
if not events:
LOG.info("trash-calendar: nothing to sync this run")
return 0
sync_to_caldav(events)
return 0
if __name__ == "__main__":
sys.exit(main())