8.7 KiB
CalDAV / Nextcloud calendar integration
Notes for Phase 8 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:
- Rotating it means editing three files.
digest-engine.env,chores.env, andtrash-calendar.envon 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." - 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-engineholds is fully capable of deleting every event you own — what stops it is thatingest/caldav.pyonly 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
- None of this has been run against a real Nextcloud instance from this repo.
digest-engine/ingest/caldav.pyis written against thecaldavlibrary's documented API (sourced and dated in its own docstring);chores' busy-check andtrash-calendar's write path have never been executed against a live server at all. CALDAV_TARGET_CALENDARmatching 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.- 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.
- 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.