# CalDAV / Nextcloud calendar integration Notes for [Phase 8](project-plan.md) and for the three services in this repo that talk to the household calendar directly. **This repo does not deploy Nextcloud.** It's listed in the project plan's hardware table as an *already running* household service (`§ "Nextcloud instance | Calendar backend (CalDAV sync target)"`), and `setup-container-host.sh` never touches it. Everything below assumes you already have one and are pointing this stack at it. Like `network-integration.md`, this is **guidance, not automation** — nothing under this repo configures Nextcloud or Home Assistant for you. ## Who talks to the calendar, and in which direction Four independent clients, and the direction of each matters more than the count: | Client | Direction | What it does | |---|---|---| | `digest-engine/ingest/caldav.py` | **read-only** | Pulls events in the digest window for the household section of the quarter-daily digest | | `chores/check.py` (`_household_currently_busy()`) | **read-only** | Asks "is there a currently-active event whose summary matches a quiet keyword?" before nudging anyone | | `trash-calendar/sync.py` | **write** (create only) | Writes bin-collection dates from the municipal ICS feed onto the household calendar | | Home Assistant's CalDAV integration | read/write | The general-purpose bridge — `calendar.*` entities, `calendar.create_event` | These are **four separate connections to the same server**, not one shared client. That is deliberate: `chores` runs as a oneshot timer job with no HTTP surface, `digest-engine` runs on its own schedule, and neither should have to be up for the other to work. ## One app password, not four All three of this repo's services deliberately read the **same** environment variable names — `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_VERIFY_TLS` — and expect the **same single Nextcloud app password** pasted into each of their env files. Create it at: **Nextcloud → Settings → Security → Devices & sessions → Create new app password.** **Not the account password.** An app password is revocable on its own, scoped to this one integration, and keeps the account password out of files on the container host. It's also *mandatory* once two-factor authentication is on: the DAV endpoints have no way to prompt for a second factor, so a 2FA account simply cannot authenticate to CalDAV with its normal password. ### What that choice actually costs Sharing one credential is the right call at household scale — four credentials to create, rotate and revoke, for four clients that are all equally trusted, is bookkeeping without a security gain. But be clear about the two consequences: 1. **Rotating it means editing three files.** `digest-engine.env`, `chores.env`, and `trash-calendar.env` on the container host, plus re-entering it in Home Assistant's CalDAV integration. Nothing propagates it for you, and a service left with the old value fails *quietly* — every one of these clients is built to degrade rather than crash, so a stale credential looks like "the calendar had nothing to say." 2. **The read-only invariant is a code property, not a permission boundary.** A Nextcloud app password cannot be scoped to read-only, nor to a single calendar. The credential `digest-engine` holds is fully capable of deleting every event you own — what stops it is that `ingest/caldav.py` only ever issues reads, an invariant asserted in its own docstring and in the project plan's Phase 12 rule. If that module ever grew a write, no permission on the server side would catch it. Treat changes to those files accordingly. ## What each service needs beyond the shared four | Variable | Used by | Meaning | |---|---|---| | `CALDAV_CALENDARS` | digest-engine | Which calendars to read (blank = all discovered) | | `CALDAV_LOOKAHEAD_HOURS` | digest-engine | How far forward to look; the window is deliberately asymmetric (back over the digest window, forward over this) because a calendar is mostly useful forwards | | `CALDAV_MAX_EVENTS` | digest-engine | Cap on events fed into the LLM context | | `CALDAV_QUIET_KEYWORDS` | chores | Summary substrings that mean "don't nudge right now" — default `busy,meeting,call,movie,sleep` | | `CALDAV_TARGET_CALENDAR` | trash-calendar | The **one** calendar it writes into, by display name | `CALDAV_TARGET_CALENDAR` being singular is intentional: the read paths can happily span several calendars, but a writer that had to *guess* which of several calendars a bin-collection event belongs in would eventually guess wrong, in someone else's calendar. ## The write path's ownership invariant `trash-calendar` is the only thing here that writes, and it constrains itself to events it created: - Every event it creates gets a UID prefixed `smartesthome-trash-`, derived deterministically from the source feed's own content. - It only ever *creates* under that prefix, and only ever checks for existence before creating. - It never reads, modifies, or deletes anything else in the target calendar. - A re-run with nothing new in the feed touches nothing at all. So the blast radius of a bug there is "duplicate or missing bin-day events", not "someone's appointments are gone". If you add another writer later, copy this shape. ## URL form Point `CALDAV_URL` at Nextcloud's **DAV root**: ``` https://cloud.example.com/remote.php/dav ``` The `caldav` library discovers the principal and its calendars from there. Nextcloud also documents the per-user form (`https:///remote.php/dav/principals/users//`); either works. ## Two traps worth knowing before you debug them **Recurring events without expansion.** A weekly recurring event fetched without `expand=True` comes back *once*, as its original master VEVENT carrying an RRULE — so a naive client reports the meeting on the day it was first created, possibly years ago, and nothing looks obviously broken. `digest-engine` passes `expand=True` and retries for servers that reject it outright. Any new client needs the same. **`CALDAV_VERIFY_TLS=false` is for a self-signed internal cert, and nothing else.** It disables certificate verification entirely, which on a LAN-only Nextcloud behind your own CA is a reasonable trade, and on anything reachable beyond the LAN is a straightforward man-in-the-middle hole. If your Nextcloud has a real certificate — and it should, Let's Encrypt is free — leave this `true`. ## Home Assistant's own integration Separate from this repo's three clients, and set up in HA's UI rather than by anything here: **Settings → Devices & Services → Add Integration → CalDAV**, same URL and app password. Phase 8's own guidance still stands: **create recurring events directly in Nextcloud, not through HA**, and gate delete/move actions behind a confirmation step. HA's CalDAV bridge is good at reading and at creating simple one-off events; recurrence rules are where the impedance mismatch between the integration and the server shows up. ## Failure behaviour Every client here fails soft, which is correct and also means an outage is quiet: | Client | If Nextcloud is unreachable | |---|---| | digest-engine | Household section renders without calendar content | | chores | `_household_currently_busy()` returns "not busy" — **fails open on purpose**, so a broken calendar check can never be the reason chores stop being nudged | | trash-calendar | The sync run is skipped; tomorrow's timer tries again | There is no alerting on any of this. A Nextcloud that's been down for a week looks identical to a week with no calendar events — the same "silence isn't a signal" gap noted in the project plan's open decision #38. ## Still unverified 1. **None of this has been run against a real Nextcloud instance** from this repo. `digest-engine/ingest/caldav.py` is written against the `caldav` library's documented API (sourced and dated in its own docstring); `chores`' busy-check and `trash-calendar`'s write path have never been executed against a live server at all. 2. **`CALDAV_TARGET_CALENDAR` matching is by display name**, which is whatever the calendar is called in Nextcloud's UI — not a URL or an ID. Renaming the calendar silently breaks the trash sync. 3. **Nobody has checked what happens when the app password is revoked** mid-operation — expected to surface as an auth error each client swallows into its normal degrade path, i.e. silently, but that's reasoning rather than observation. 4. **The quiet-keyword list is a guess at how this household actually labels events** (`busy,meeting,call,movie,sleep`). It matches on substrings of the summary, so an event called "Call with the bank" pauses every chore nudge in the house for its duration — which may or may not be what you want.