# 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), and when the config or overrides file's mtime 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 only on rescan. 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.