A house spirit that gets up "sleepy" on the morning of a six o'clock flight is not wrong, just uninformed. The information was in three calendars all along. Getting it into the house turned out to be a study in small, specific traps.
The house did not know about the six o'clock flight until the morning of the six o'clock flight, at which point it greeted the household as sleepy. It was not wrong. It was uninformed, which is worse, because the information had been sitting in three calendars the whole time.
Read-only, on purpose
The calendar provider offers a proper API with OAuth, and a "secret address in iCal format" for every calendar. We chose the second. The core integration that subscribes to an iCal URL needs no cloud project, no token refresh and no re-authorisation ritual; it needs a URL kept in the secrets file. It is also read-only, which we verified in the integration's source rather than assuming (no create or delete capability declared). If the house ever needs to write events, the OAuth integration exists and supports both; until then, reading is the whole job.
Once a day is not a calendar
The first entry added to a calendar as a test did not appear. The provider's feed had it within a minute; the integration polls that feed once every twenty-four hours, a constant in its coordinator, and had last looked at breakfast. An hourly automation now asks each calendar entity to refresh, which does re-fetch the feed (we tested that with an edited entry rather than trusting the service description). Three small HTTP requests an hour is a fair price for a spirit that knows about this afternoon.
The trip title lies
The itinerary service publishes a calendar too, and its trip container events carry a title and a location that name the event: a conference, a city. The actual route is elsewhere, in the hotel check-in and check-out entries with full addresses, and in the flights.
One upcoming trip is titled after a conference in one capital and consists entirely of nights in two other countries. So the route is rebuilt from lodging: pair each check-in with its check-out, take the city and country from the address, order by date, and only fall back to the title if there is no lodging at all.
Addresses, naturally, do not agree on where the city goes. Some put it before the postal code, some after, some at the very front before a comma. The parser looks for known cities first (the container events helpfully say "is in City, Country", and hotel names usually contain the city), then for a bare first element without digits, and only then for the token next to the postal code.
One more trap for the road: the container's end date is exclusive, as iCal end dates are, so a trip that ends on the second showed as ending on the third until we subtracted a day. The result is a sensor with days-until-departure and a route string in the household's language, and the display's morning thoughts say "trip in three days: Zagreb".
A reminder is not an appointment
The early-riser rule was simple: any timed event before nine within the next twenty hours makes the spirit "eager" in the morning instead of sleepy, and exempts a short night from the sleep score. The first thing it matched was a five-minute reminder to take a vitamin at eight. Reminders are events too. So an early event must last at least thirty minutes to count; a flight or an appointment does, a pill does not. The reminder still shows in the day's thoughts, where it belongs.
And a Jinja footnote that cost twenty minutes: a variable set inside a for loop
does not survive the loop. The thoughts template built its list of calendar lines in a loop over
calendars and emerged with an empty list; the trip line, added outside the loop, appeared alone
and made the bug look like a calendar problem. Unrolling the loop fixed it; a
namespace would have too.
The house now knows where we are going before we have packed. It has not yet offered to help, which we take as a sign of good breeding.