# cal-notif design Date: 2026-09-27 ## Goal A desktop daemon that notifies of upcoming calendar events. Each event's own alarms (VALARM) set the cadence. Events without alarms can get custom notification offsets, set from a rofi picker. Every notification is a popup plus a spoken announcement rendered locally. ## Scope In: VEVENTs from local `.ics` files, popups with a snooze action, local TTS, a rofi picker for per-event overrides. Out: VTODO, network access of any kind (vdirsyncer already syncs), EMAIL and AUDIO alarm actions (every alarm becomes popup + voice), persistent fired-alarm state. ## Stack - One Python file, `cal-notif`, run by system `/usr/bin/python3`. - Libraries already on the system: `icalendar`, `dateutil` (RRULE parsing), `kokoro_onnx` (TTS). Stdlib `tomllib` for config, `zoneinfo` for timezones. - External programs: `notify-send`, `rofi`, `aplay`. - License GPLv2 (v2-only). ## Interface - `cal-notif daemon`: long-running loop. Started from the compositor's autostart (no systemd on the target system). - `cal-notif pick`: rofi picker, bound to a key. - `cal-notif say ""`: speak text through the configured voice and exit. The daemon runs this as a subprocess; it doubles as a manual voice test. ## Config `~/.config/cal-notif/config.toml`. Every key has a built-in default, so a missing file works (VALARMs only, voice on). ```toml calendars_dir = "~/.local/share/calendars" # vdirsyncer filesystem storage # A calendar's name is its `displayname` file (vdirsyncer metadata), else # its directory name. [defaults] keys and the picker use that name. snooze = "5m" rofi_theme = "~/.config/rofi/udt/list.rasi" late = "10m" # deliver alarms missed by at most this much [defaults] # per calendar directory name, only for events with no VALARM birthdays = ["1d", "9h"] "*" = [] # any other calendar: silent unless VALARM [voice] enabled = true model = "/data/voice-models/kokoro/kokoro-v1.0.onnx" voices = "/data/voice-models/kokoro/voices-v1.0.bin" lang = "it" blend = { if_sara = 0.8, af_bella = 0.2 } lead_silence = 0.3 # seconds of silence before speech, see Voice say = "Tra {in}: {summary}" say_now = "Adesso: {summary}" ``` Per-event overrides live in a separate file, `~/.config/cal-notif/overrides.toml`, written only by `pick` (hand edits also work). Keeping them apart means `pick` never rewrites the hand-edited config and its comments. ```toml "some-uid@example.org" = ["1h", "10m"] # keyed by event UID; replaces VALARMs "other-uid@example.org" = [] # silenced ``` Durations are strings of `d`, `h`, `m` parts, e.g. `"1d 2h 15m"` (one offset of 26h15m). Several offsets are a list in TOML, or comma-separated in the picker (`1d, 1h` is two alarms). A calendar in `[defaults]` may also set `voice = false` via a table form (`birthdays = { offsets = ["1d"], voice = false }`). ## Alarm precedence For each event occurrence, the offsets are the first of: 1. `overrides.toml[UID]`, if present (an empty list silences the event). 2. The event's own VALARM triggers: relative (`-PT15M`, relative to start or end) and absolute (`VALUE=DATE-TIME`). 3. `[defaults][]`, else `[defaults]["*"]`. ## Daemon loop Every 30 s: 1. **Rescan** the `.ics` files when any calendar directory's mtime changed (vdirsyncer rewrites files there), when the config or overrides file's mtime changed, and every hour regardless, so the window keeps moving even when nothing changed. 2. **Expand** events into concrete occurrences within a window `[now, now + largest offset in use + 1 day]`. The window follows the largest offset, so a "1 week before" alarm is never dropped. - RRULE via `dateutil.rrule`, EXDATE removes occurrences. - A VEVENT with RECURRENCE-ID replaces the occurrence it names, with its own summary, time and alarms. - All-day events (`VALUE=DATE`) start at local midnight; offsets count from there. - Floating times are local time. The result is a sorted list of `(fire_time, occurrence, offset)`, recomputed on rescan, which also runs every hour so the window keeps moving. 3. **Fire** every entry with `last_tick < fire_time <= now`, skipping those older than `now - late` and those whose occurrence already started (except offset 0, the "now" alarm). 4. **Notify**: spawn `notify-send --wait --app-name=cal-notif --action=snooze=Snooze --action=dismiss=Dismiss` with summary, start time, location. Urgency critical for the offset-0 alarm. Spawn `cal-notif say` alongside. Neither blocks the loop; finished children are polled each tick. 5. **Snooze**: when a `notify-send` child prints `snooze`, queue a one-off re-fire at `now + snooze` for the same occurrence. It speaks again. The notification server on the target system advertises `actions` and returns the clicked action id to `notify-send --wait` (verified 2026-09-27). Deliberate simplification: fired alarms are kept in memory only. A restart within the `late` window repeats those popups once. ## Voice `cal-notif say` loads Kokoro, builds the voice from the blend of style vectors, synthesizes the text and pipes 16-bit mono PCM to `aplay -q -r -f S16_LE -c 1`. - Kokoro loads per announcement (about 0.3 s) instead of staying resident in the daemon: alarms are rare, and a resident model costs 300+ MB. - **Leading silence**: `lead_silence` seconds of zero samples are written before the speech. A freshly started playback stream wakes the suspended audio sink and loses its first ~0.2 s, which ate the first word of announcements in a previous project. Default 0.3 s, tunable per machine. - `{in}` is the offset spoken in words ("dieci minuti", "un giorno e due ore"), from a small Italian number-to-words function covering 1-99 plus units. Kokoro reads "10m" badly. - If voice is disabled, or the model files are missing, only the popup is shown; a missing model is reported once on stderr. ## Picker (`pick`) Both menus follow the unified-desktop-theme (udt) rofi theming: every call is `rofi -dmenu -i -theme ~/.config/rofi/udt/list.rasi`, the same layout rofipass uses. `list.rasi` is the udt layout that keeps both the inputbar (needed to type free-text durations) and the `message` widget (needed for `-mesg`); `menu.rasi` has neither. No colors or layout are set by cal-notif itself, so a udt scheme switch applies with no change here. The theme path is a config key (`rofi_theme`) defaulting to that file. 1. rofi lists occurrences in the next 30 days: `dd.mm HH:MM · calendar · summary · [current offsets]`. 2. Second rofi menu for the chosen event, with the event's summary and time in `-mesg`: presets `10m`, `30m`, `1h`, `1d`, `1d, 1h`, `none` (silence), `reset` (drop override). Free text is accepted and parsed as durations; invalid input re-prompts with the error shown in `-mesg`. rofi exits non-zero on Escape: treated as cancel, never as an error. 3. `overrides.toml` is rewritten whole (flat `"uid" = [..]` lines, trivial to emit without a TOML writer) via a temp file and rename. The daemon picks it up on its next tick through the mtime check. ## Error handling - An unparsable `.ics` file is skipped with one stderr line naming it; the others still load. - An invalid config at startup exits with the error. An invalid config on reload keeps the previous one and logs. - `notify-send`, `aplay` or `rofi` missing: fail at startup for the daemon (`notify-send`) or the picker (`rofi`); `aplay` missing disables voice. ## Testing One `test_cal_notif.py` with plain asserts, fixtures as inline `.ics` strings: - duration parsing and Italian spoken form - precedence: override, VALARM, calendar default, `*` - expansion: RRULE, EXDATE, RECURRENCE-ID, all-day, window sized by largest offset - fire window: `late` cutoff, started occurrences skipped, snooze re-fire Voice and popups are checked by hand: `cal-notif say "prova"` and an event created a few minutes ahead.