SmartestHome/docs/caldav-integration.md

160 lines
8.7 KiB
Markdown

# 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://<host>/remote.php/dav/principals/users/<username>/`); 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.