Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip <noreply@paperclip.ing>
131 lines
5.6 KiB
Markdown
131 lines
5.6 KiB
Markdown
# Phase 6 — the calendar write (Sam's Home Assistant calendar)
|
|
|
|
Status: **code complete and tested; the calendar target needs Sam's decision**
|
|
(AUD-110). The service now writes important school events through the Home
|
|
Assistant REST API. Nothing is written until a calendar entity is chosen.
|
|
|
|
## What it does
|
|
|
|
`school calendar push` selects the important school events that are not in the
|
|
calendar yet and writes them with the Home Assistant service
|
|
`calendar.create_event`. The school is never touched (AGENTS.md §10 rule 1); the
|
|
only write is to the calendar (rule 7 — no approval needed).
|
|
|
|
- **Important** is the Phase 3 classifier: `internal/parse/importance.go` marks
|
|
an event `high` when it is a **school event**, and `normal` when it is a
|
|
routine class period. A Compass week grid writes a class as
|
|
`5 - 11HLEGB5 - D8 - LHT` and an event as
|
|
`End of Year Senior Music Concert - HALL (Senior Music Concert) - KSH, CFR`.
|
|
The first part of a lesson is a period label (`5`, `C`, `RLE GROUP D`).
|
|
- Only **future** events are written: a past lesson is history, not a plan.
|
|
- Which events qualify is visible before anything is written:
|
|
|
|
```bash
|
|
scripts/calendar-push.sh push --dry-run
|
|
```
|
|
|
|
## Idempotency (a re-run never duplicates)
|
|
|
|
Two independent guards:
|
|
|
|
1. The item stores the Home Assistant event id (`items.ha_event_uid`). An item
|
|
that already has one is not a candidate again.
|
|
2. The event description carries a marker, `source: [compass-nhs:<item id>]`. A
|
|
run lists the calendar first and adopts any event that already carries the
|
|
marker for that item, even when Home Assistant reports no event id.
|
|
|
|
`items.ha_pushed_at` records when the item was written.
|
|
|
|
## Fail loudly (FR-12)
|
|
|
|
A failed calendar write is never swallowed.
|
|
|
|
- Every attempt is appended to `calendar_pushes` (`ok = 0` plus the error).
|
|
- The dashboard shows a **Calendar** banner: `CALENDAR NOT CONFIGURED`,
|
|
`CALENDAR WRITE FAILED` with the Home Assistant reason, or the healthy state.
|
|
- The item screen shows the failure in place of a confirmation.
|
|
- `scripts/calendar-push.sh push` exits non-zero when any write failed.
|
|
|
|
A failed write leaves the item as a candidate, so the next run retries it.
|
|
|
|
## Configuration (names only — never the values)
|
|
|
|
| Variable | Meaning |
|
|
|---|---|
|
|
| `SCHOOL_HA_URL` | Home Assistant base URL, for example `http://192.168.20.30:8123` |
|
|
| `SCHOOL_HA_TOKEN` | long-lived access token (**secret**) |
|
|
| `SCHOOL_HA_CALENDAR_ENTITY` | the calendar entity to write to, for example `calendar.school` |
|
|
| `SCHOOL_HA_INSECURE` | `true` to skip TLS verification for the internal `.lab` certificate |
|
|
| `SCHOOL_HA_TIMEOUT` | per-request timeout (default `20s`) |
|
|
|
|
The values live in `environment.d/school-service.env`, which is **gitignored**
|
|
(AGENTS.md §10 rule 3). The token is never logged, never printed by the CLI and
|
|
never stored in SQLite.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
scripts/calendar-push.sh entities # list calendar.* entities and whether they are writable
|
|
scripts/calendar-push.sh status # target, reachability, pending events, last push
|
|
scripts/calendar-push.sh push # write the pending important events
|
|
scripts/calendar-push.sh events # read the calendar back (evidence)
|
|
scripts/calendar-push.sh push --dry-run --json
|
|
```
|
|
|
|
`make calendar CALENDAR_ARGS='push --dry-run'` runs the same thing.
|
|
|
|
The item screen has an **Add to calendar** button for an important future event,
|
|
and shows `in the calendar` with the Home Assistant event id once it is written.
|
|
|
|
## The open decision: which calendar entity?
|
|
|
|
The Home Assistant instance (`192.168.20.30`, HA 2026.9.4) has three calendar
|
|
entities, and only two are writable:
|
|
|
|
| Entity | Writable | Backing |
|
|
|---|---|---|
|
|
| `calendar.samuelrolfe_googlemail_com` | yes | Google, synced into Home Assistant |
|
|
| `calendar.birthdays` | yes | Google |
|
|
| `calendar.holidays_in_australia` | no | Google |
|
|
|
|
The brief says **Sam's Home Assistant calendar (not Google)**. Today Home
|
|
Assistant has **no non-Google writable calendar**: the `local_calendar`
|
|
integration is not installed, and no CalDAV/remote calendar is configured.
|
|
|
|
So the target must be chosen before the event is written:
|
|
|
|
1. **Add a Local Calendar to Home Assistant** (recommended). One Home Assistant
|
|
config-flow entry creates `calendar.school`. It is Home-Assistant-local, never
|
|
touches Google, and is entirely additive. The service then uses
|
|
`SCHOOL_HA_CALENDAR_ENTITY=calendar.school`.
|
|
2. **Use `calendar.samuelrolfe_googlemail_com`.** No Home Assistant change, but
|
|
every school event also appears in Sam's personal Google Calendar.
|
|
|
|
The question is posted on AUD-110. The code needs only the configuration line
|
|
changed once the answer is in.
|
|
|
|
## Where the code lives
|
|
|
|
```
|
|
internal/calendar/ha.go Home Assistant REST client (states, create, get, delete)
|
|
internal/calendar/sync.go selection, idempotency, the push log
|
|
internal/store/calendar.go candidates, mark-synced, the append-only push log
|
|
internal/store/migrations/003_calendar.sql
|
|
cmd/school/calendar.go `school calendar entities|status|push|events`
|
|
internal/web/server.go the item action + the fail-loud calendar banner
|
|
scripts/calendar-push.sh one-off container runner (no service restart)
|
|
```
|
|
|
|
## Verification
|
|
|
|
```bash
|
|
make test # go test ./...
|
|
```
|
|
|
|
The tests cover the Home Assistant client against a fake Home Assistant (create,
|
|
read-back, error mapping), the sync run (write once, adopt by marker, dry run,
|
|
loud failure, refuse-to-write-blind), the store (candidates, the push log) and
|
|
the web surface (the action, the CSRF guard, the failure banner).
|
|
|
|
Not tested here: the live Home Assistant write. That needs the entity decision.
|