160 lines
8.7 KiB
Markdown
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.
|