CalDAV Calendar
Meeting reminders with a countdown, then a red BUSY while it runs, from any CalDAV or ICS calendar - iCloud, Fastmail, Nextcloud, PrivateEmail or an Outlook link.
By kurok · ▲ 0 · Rate and Discuss
How It Works
busybar-calendar
Calendar reminders for the BUSY Bar from any CalDAV account or ICS feed, shown exactly the way the bar shows its built-in Google Calendar events.

The bar's own Google Calendar integration runs in BUSY Cloud and only talks to Google. This does the same thing for iCloud, Fastmail, Nextcloud, PrivateEmail, self-hosted CalDAV servers, and any calendar published as an ICS link (Outlook, Google, Proton). It runs on a machine of yours and draws through the bar's HTTP API, via BUSY Cloud or on the LAN. The events look the same as Google's; only where they come from is different. Requested upstream as busybar-firmware#1086.
On the bar
Every picture here is a frame read back from a real bar (firmware 1.2.4)
through GET /api/screen.
| Before the event - a red calendar, the title, and a countdown to the start. | ![]() |
| During the event - a red calendar, the title, a countdown to the end and a red BUSY ... | ![]() |
| Marked "Maybe" (tentative): a yellow calendar and a yellow BUSY, in the reminder and during the event, so you can tell it from one you accepted. | ![]() |
... or one of the bar's own statuses, such as meeting, on_call or dnd. |
![]() |
| Long titles scroll beside the icon. | ![]() |
Titles in other scripts are transliterated for the bar's bitmap fonts (Планування спринту becomes Planuvannya sprintu) ... |
![]() |
... or drawn as they are with title_font = "global", on one line beside the countdown. |
![]() |
Event name hidden for a calendar (show_name = false). |
![]() |
Like Google events, these go in above an active BUSY session, so they show on top of any status. A sound plays as a reminder opens and as an event starts, using the bar's own calendar sounds. After the event, the bar goes back to whatever it was showing.
Features
- CalDAV with discovery (
/.well-known/caldav, principal, calendar home), or a calendar URL directly; Basic or Digest auth. - Calendar filters:
include/excludeby name, id or URL, with wildcards (exclude = ["Room *"]). - ICS / webcal feeds, fetched with
If-None-Match. - Recurring events expanded locally (RRULE, EXDATE, moved occurrences), timezones and floating times handled.
- Google's per-calendar settings (reminder time, sound, show name, status), set once as defaults and overridden per source or per calendar.
- Rules by title, from any calendar: keep
Focus timeoff the bar, showLunch*as the bar's own lunch status. - Overlaps settled by your response: an event you accepted wins over one you marked tentative, which wins over one you have not answered - when they start together and when they are shifted against each other, so a "maybe" never covers a meeting you said yes to; then, as in Google's integration, the newest event wins.
- "Maybe" in yellow: an event you marked tentative gets a yellow
calendar and a yellow BUSY, so you can tell it from one you accepted
(
display.tentative_yellow, default on). - Google's filters: no all-day or 24-hour-plus events. Also skipped: cancelled events, events marked free, invitations you declined.
- On time: the loop wakes at the next change, not on the next poll.
- Live config: the running service applies an edited config within a minute; an edit that does not load is left out, and the log says why.
- Resilient: a failing source keeps its last good events; a bar that cannot be reached is retried; drawings expire on the bar by themselves if the service stops.
- No secrets in files: passwords and tokens come from the environment or a command such as the macOS Keychain.
Get started
uv tool install git+https://github.com/kurok/busybar-calendar
busybar-calendar init # a few questions: how to reach the bar, which calendar
busybar-calendar check # proves it works, or says what to fix
busybar-calendar install # runs it in the background from now on
init asks how to reach the bar (BUSY Cloud, Wi-Fi or USB) and which
calendar to read, keeps the passwords in the macOS Keychain (libsecret on
Linux), signs in so you can pick calendars, and writes
~/.config/busybar-calendar/config.toml. To write the config yourself,
start from config.example.toml.
Everyday commands
| Command | |
|---|---|
busybar-calendar |
Where you stand: the first steps before setup, the status after. |
busybar-calendar status |
Is it running, what did it last log, what is on the bar now and next. |
busybar-calendar check |
The bar, its token and every calendar, with what to fix. |
busybar-calendar add |
Another calendar, by the same questions as init. |
busybar-calendar restart |
After a new password or a [bar] change; other config edits apply by themselves within a minute. |
busybar-calendar upcoming |
The events the bar will show, and when; tentative ones marked. |
busybar-calendar calendars |
What each CalDAV account has, with ids, for include. |
busybar-calendar replay |
Tomorrow's events on the bar, a minute each. |
busybar-calendar demo |
A sample reminder on the bar, to see the look. |
busybar-calendar uninstall |
Stop and remove the background service. |
status and check also take --json, for scripts. A newer version is
uv tool upgrade busybar-calendar && busybar-calendar restart - or, with
busybar-calendar install --auto-update, the service fetches it by itself
every time it starts or restarts.
As a BUSY Bar gallery app
app.py follows the community gallery's
conventions: one entrypoint, --host (default 10.0.4.20, the bar over
USB), secrets from the environment only. It runs from a clone:
git clone https://github.com/kurok/busybar-calendar.git && cd busybar-calendar
pip install -r requirements.txt
python app.py # the config init wrote; a demo schedule before that
python app.py --host 192.168.1.50 --ics "https://example.com/work.ics"
CALDAV_PASSWORD=... python app.py --caldav https://caldav.icloud.com --user me@icloud.com
BUSYBAR_TOKEN=... python app.py --cloud --ics "https://example.com/work.ics"
CALDAV_PASSWORD is the calendar's password, BUSYBAR_TOKEN the bar's HTTP
API password or, with --cloud, a BUSY Cloud token. On macOS the first
start after installing the requirements takes a few seconds while the system
checks the new libraries; later starts take a fraction of a second.
Settings
The per-calendar settings are the Google Calendar integration's:
| Google Calendar integration | config.toml |
|---|---|
| Event reminders: on/off | reminder.enabled |
| How long before the event | reminder.minutes_before |
| Sound | reminder.sound |
| Show event name | reminder.show_name |
| Status during events: on/off | status.enabled |
| Event name, or a status from the list | status.show: name, meeting, on_call, dnd, ... |
| Sound | status.sound |
| (not in Google's) Busy status for events you marked tentative | status.tentative (default on) |
| (not in Google's) Yellow calendar and BUSY for events you marked tentative | display.tentative_yellow (default on) |
They layer: [defaults] for every calendar, then per source, then per
calendar ([sources.calendars."Work".reminder]); a default set once is the
account-level setting BUSY Cloud does not have yet
(busybar-firmware#1083).
Last come the [[titles]] rules, which match event titles in any calendar:
[[titles]]
match = ["Focus time", "Commute*"] # * and ? are wildcards; case does not matter
reminder.enabled = false
status.enabled = false
[display] sets the look: the icons (red_calendar or the bar's own
animation), the word beside the countdown (busy_label, busy_color) and
whether events you marked tentative are drawn in yellow (tentative_yellow).
Every option, with its default, is in the
configuration reference.
Documentation
- Setup - the bar, calendar providers, secrets, running as a service.
- Troubleshooting - what
checkandstatussay when something is wrong, and the fix. - Configuration reference - every option and its default, generated from the code.
- Changelog - what changed in each version.
- How it works - the design, the API calls, and what was measured on the hardware.
Development
uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check .
scripts/capture_screens.py regenerates docs/images from a real bar.
License
MIT, except the red and yellow calendar icons in src/busybar_calendar/icons.py:
they are redrawn from Google's Noto Emoji
📅 and stay under the SIL Open Font License 1.1
(LICENSES/noto-emoji-OFL.txt).
Declared Dependencies
# busylib: Flipper's official BUSY Bar client (pre-approved) - LAN and BUSY Cloud.
busylib>=2.6,<3
# The calendar side, which the standard library cannot do safely or correctly:
# httpx speaks CalDAV's PROPFIND/REPORT with Basic and Digest auth,
httpx>=0.27
# icalendar parses the events and recurring-ical-events expands RRULE/EXDATE
# and moved occurrences in their own timezones,
icalendar>=6
recurring-ical-events>=3.3
# defusedxml parses the server's XML without entity expansion attacks,
defusedxml>=0.7
# anyascii turns titles in other scripts into text the bar's bitmap fonts draw.
anyascii>=0.3Source Files
These are the mirrored source bytes. The complete package also includes every asset and supporting file.
CHANGELOG.md
# Changelog
All notable changes to busybar-calendar. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.8.0] - 2026-10-20
### Added
- Events you marked tentative ("Maybe") are drawn in yellow: a yellow calendar
and a yellow `BUSY`, in the reminder and during the event, so they read
differently from the events you accepted. `[display] tentative_yellow = false`
draws them in red like the rest. With `reminder_icon` / `event_icon` set to
`animation` only the label turns yellow, as the bar's own animation has no
yellow version.
### Changed
- `scripts/capture_screens.py` draws the tentative status for the README, and
its docs now say that a capture takes a running service's screen down until
the service redraws.
- The screen is redrawn when you change your answer to an event that is
already up - marked "maybe" from your phone, say - at the next refresh,
not only when the screen changes.
- CI tests Python 3.12, 3.14 and 3.15 (it was 3.11, 3.12 and 3.13), and its
actions run on Node 24. `requires-python` stays `>=3.11`, which is no
longer tested.
## [0.7.2] - 2026-10-13
### Fixed
- `restart` on macOS waited up to five minutes when the service had
started recently: `launchctl kickstart -k` waits out the agent's restart
throttle. It now unloads the agent and loads it again, which starts it
at once - and also works for an agent that was not loaded. With
`--auto-update`, that makes `restart` the quick way to update.
## [0.7.1] - 2026-10-13
### Fixed
- `install` over a running launch agent could fail with
`launchctl bootstrap ... failed (exit 5)`, leaving the service stopped:
`launchctl bootout` returns before the old agent is gone. `install` now
waits for it (up to 10 seconds) before loading the new one.
## [0.7.0] - 2026-10-13
### Added
- `busybar-calendar install --auto-update`: the background service updates
itself every time it starts - at login, on `restart`, after it stopped -
with `uv tool upgrade busybar-calendar`, before it reads the config, then
runs the new version at once. A failed update (no network yet at login)
starts the version it has. It needs the one-line install from git, and
`install` says so when it is not that. `status` shows
`updates itself at each start`, and `--json` has `service.auto_update`.
- `run --auto-update`, what such a service runs.
## [0.6.1] - 2026-10-13
### Fixed
- A CalDAV account lists its calendars again every hour, to find new
ones; a timeout or server error at that moment no longer drops the whole
refresh. It goes on with the calendars it knows, says so in the log, and
lists them again at the next refresh. A refused password still stops the
service, as before.
## [0.6.0] - 2026-10-13
### Added
- `upcoming` names the `[[titles]]` patterns that apply to each event, so a
rule that hides or changes one shows why: `(hidden, titles 'Focus*')`.
### Fixed
- `upcoming` no longer lists `status` for a tentative event when
`status.tentative` is off; it shows only the reminder, as the bar does.
## [0.5.1] - 2026-10-13
### Fixed
- `status`, and `busybar-calendar` on its own, report a config that does
not load - with its problems and the fact that a running service keeps
the config it last loaded - instead of stopping at the first error and
showing nothing about the service. `status` exits 1 then, and its
`--json` has a `config` block with the path and the problems.
## [0.5.0] - 2026-10-13
### Added
- A running service follows edits to its config: they apply within a
minute, with the calendars read again at once and the screen redrawn
(without a second chime). An edit that does not load leaves the service
on the config it has, and the log says what is wrong; a change under
`[bar]` waits for `busybar-calendar restart`.
## [0.4.0] - 2026-10-13
### Added
- `[[titles]]` rules: reminder and status settings for the events whose
title matches (`*` and `?` wildcards, any case), from any calendar, on top
of the calendar's own - to keep `Focus time` off the bar, or show `Lunch*`
as the bar's own lunch status. `upcoming` shows what each event ends up
with, and `demo --title` draws with the rules for that title.
### Fixed
- A bad reminder or status value now says where it is
(`sources.0.calendars.Team.reminder: minutes_before: ...`), not just which
key.
## [0.3.2] - 2026-10-12
### Changed
- Install with one line, no clone needed:
`uv tool install git+https://github.com/kurok/busybar-calendar`, and
upgrade with `uv tool upgrade busybar-calendar`.
### Fixed
- `status` no longer offers `restart` as the way to a newer version when the
service runs from another install (a checkout run with `uv run`, say): a
restart would start the old version again. It names the install to upgrade,
and the `uv tool upgrade` command when that install came from git.
## [0.3.1] - 2026-10-12
### Added
- `status` checks the bar with one light read-only call and says whether it
answers, refuses the token, or does not answer at all - with what to do -
so a stuck bar no longer looks like a healthy setup.
### Fixed
- Event titles are capped at 120 characters (a longer one only scrolls
longer). A draw request with a title of thousands of characters left a
bar's HTTP API hanging, on the LAN and through BUSY Cloud, until the bar
was power-cycled.
## [0.3.0] - 2026-10-12
### Changed
- `init` and `add` ask for the calendar provider from a list - iCloud,
Fastmail, PrivateEmail, Nextcloud or another server - guessed from your
address, instead of for the server's URL; Nextcloud needs only its host.
### Added
- `busybar-calendar` on its own says where you stand: the first steps before
there is a config, the status after, instead of a usage error.
- `status` shows the version the background service runs, read from the line
it now logs at start, and says to `restart` when that is not the version
installed; the setup guide has an "Upgrading" section.
- Tentative events in published feeds, which carry no attendees, now count as
tentative: Outlook's busy status or `STATUS:TENTATIVE` decides there, for
overlaps and for `status.tentative`.
### Fixed
- A Keychain / libsecret prompt that failed - a mistyped retype - ended
`init` and `add` with a traceback; it is now asked again, up to three times,
then reported in one line.
- A failing `launchctl` or `systemctl` ended `install` with a traceback; it is
now one line naming the command and its exit code.
- The service remembered every screen it had chimed for; it now forgets
them once their events are gone, so a long-running service does not grow.
- A mistyped `sync.timezone` ended every command in a traceback; it is now
rejected when the config is read, with an example of a valid name.
- `calendars` and `replay` ended in a traceback when a calendar server could
not be reached; they now say which address and why.
- A config mistake printed pydantic's whole report; it is now one line per
problem, naming the key (`bar.adress: Extra inputs are not permitted`), in
the CLI and in `app.py` alike.
- `app.py --minutes 0` (and other invalid reminder or status settings given
as flags) was accepted and only failed at the first event; it now fails at
start.
## [0.2.0] - 2026-10-12
### Added
- `busybar-calendar add`: another calendar source by the same questions as
`init`, appended to the config with its comments and layout kept, under a
name that does not clash with the others.
- `--json` for `status` and `check`: the service, the screen now and next,
and each check's finding with its fields (firmware, calendars, next event),
with times in ISO 8601.
- `busybar-calendar restart`, for the background service after a new
password or config.
- [docs/troubleshooting.md](docs/troubleshooting.md): what `check` and
`status` say for each common problem, from real failures, and the fix.
### Changed
- After `init` or `add`, an installed background service is restarted, so it
reads the new config.
- `init` and `add` keep each account's password in a Keychain / libsecret
entry of its own; two accounts on one domain no longer share one.
### Fixed
- `check` called a BUSY Cloud that refused the token "unreachable"; it now
says the token is wrong, revoked or lacks the BUSY Bar scope.
- For a source whose `include` matched no calendar, `check` added a
reassuring "no events in the next 7 days"; it now lists the calendars
there are, and the source's own warnings no longer repeat in its output.
## [0.1.0] - 2026-10-12
The first release.
### Added
- Reminders and a BUSY status on the BUSY Bar from any CalDAV account
(iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...) or ICS / webcal
link, drawn through the bar's HTTP API on the LAN or through BUSY Cloud.
- The Google Calendar integration's per-calendar settings - reminder time,
sound, show name, status during events - layered as defaults, per source
and per calendar.
- A red calendar icon and a red BUSY beside the countdown during an event;
the bar's own animations and statuses (`meeting`, `on_call`, ...) as
options.
- Overlapping events settled by your response: accepted over tentative over
not answered, then the newest event. `status.tentative` keeps tentative
events off the busy status.
- Calendar filters: `include` / `exclude` by name, id or URL, with wildcards.
- Commands: `init` (a few questions write the config, passwords go to the
Keychain or libsecret), `check` (the bar, its token and every calendar),
`install` / `uninstall` (launchd or systemd), `status` (running? now and
next), `calendars`, `upcoming`, `demo`, `replay`, `run`, `--version`.
- `app.py`, following the BUSY Bar community gallery's rules: `--host`,
`--ics`, `--caldav`, `--demo`, `--test`, secrets from the environment.
- Docs with screenshots read back from a real bar, and a configuration
reference generated from the code.
### Fixed
Found on a bar running firmware 1.2.4, and recorded in
[docs/how-it-works.md](docs/how-it-works.md):
- The countdown overlapped the title: its font sits 4px high in its box.
- The status lost its icon when it replaced a reminder as the reminder's
`display_until` fired; drawings now outlive their screen by a minute.
- A draw merges by element id, so a new layout could land on top of the old
one; a clear now comes first only when an element would be left over,
and a reminder becomes its event without a blink.
- CalDAV discovery turned PROPFIND into GET on a 302 redirect.
- A 401 or 404 from a calendar was retried forever; it now stops the
service with the reason.
- A refused draw (409) stopped the demo and replay.
- A Ctrl-C during the slow first start printed a traceback.
[Unreleased]: https://github.com/kurok/busybar-calendar/compare/v0.8.0...HEAD
[0.8.0]: https://github.com/kurok/busybar-calendar/compare/v0.7.2...v0.8.0
[0.7.2]: https://github.com/kurok/busybar-calendar/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/kurok/busybar-calendar/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/kurok/busybar-calendar/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/kurok/busybar-calendar/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/kurok/busybar-calendar/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/kurok/busybar-calendar/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/kurok/busybar-calendar/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/kurok/busybar-calendar/compare/v0.3.2...v0.4.0
[0.3.2]: https://github.com/kurok/busybar-calendar/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/kurok/busybar-calendar/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/kurok/busybar-calendar/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/kurok/busybar-calendar/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/kurok/busybar-calendar/releases/tag/v0.1.0LICENSES/noto-emoji-OFL.txt
Copyright 2013 Google LLC
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.README.md
# busybar-calendar
Calendar reminders for the [BUSY Bar](https://busy.app) from **any CalDAV
account or ICS feed**, shown exactly the way the bar shows its built-in
Google Calendar events.

The bar's own [Google Calendar integration](https://docs.busy.app/bar/apps-and-integrations/google-calendar)
runs in BUSY Cloud and only talks to Google. This does the same thing for
iCloud, Fastmail, Nextcloud, PrivateEmail, self-hosted CalDAV servers, and
any calendar published as an ICS link (Outlook, Google, Proton). It runs on
a machine of yours and draws through the bar's
[HTTP API](https://docs.busy.app/bar/dev/http-api), via BUSY Cloud or on the
LAN. The events look the same as Google's; only where they come from is
different. Requested upstream as
[busybar-firmware#1086](https://github.com/busy-app/busybar-firmware/issues/1086).
## On the bar
Every picture here is a frame read back from a real bar (firmware 1.2.4)
through `GET /api/screen`.
| | |
| --- | --- |
| **Before the event** - a red calendar, the title, and a countdown to the start. |  |
| **During the event** - a red calendar, the title, a countdown to the end and a red **BUSY** ... |  |
| **Marked "Maybe"** (tentative): a yellow calendar and a yellow **BUSY**, in the reminder and during the event, so you can tell it from one you accepted. |  |
| ... or one of the bar's own statuses, such as `meeting`, `on_call` or `dnd`. |  |
| **Long titles** scroll beside the icon. |  |
| **Titles in other scripts** are transliterated for the bar's bitmap fonts (`Планування спринту` becomes `Planuvannya sprintu`) ... |  |
| ... or drawn as they are with `title_font = "global"`, on one line beside the countdown. |  |
| **Event name hidden** for a calendar (`show_name = false`). |  |
Like Google events, these go in above an active BUSY session, so they show
on top of any status. A sound plays as a reminder opens and as an event
starts, using the bar's own calendar sounds. After the event, the bar goes
back to whatever it was showing.
## Features
- **CalDAV** with discovery (`/.well-known/caldav`, principal, calendar home),
or a calendar URL directly; Basic or Digest auth.
- **Calendar filters**: `include` / `exclude` by name, id or URL, with
wildcards (`exclude = ["Room *"]`).
- **ICS / webcal** feeds, fetched with `If-None-Match`.
- **Recurring events** expanded locally (RRULE, EXDATE, moved occurrences),
timezones and floating times handled.
- **Google's per-calendar settings** (reminder time, sound, show name,
status), set once as defaults and overridden per source or per calendar.
- **Rules by title**, from any calendar: keep `Focus time` off the bar,
show `Lunch*` as the bar's own lunch status.
- **Overlaps settled by your response**: an event you accepted wins over
one you marked tentative, which wins over one you have not answered - when
they start together and when they are shifted against each other, so a
"maybe" never covers a meeting you said yes to; then, as in Google's
integration, the newest event wins.
- **"Maybe" in yellow**: an event you marked tentative gets a yellow
calendar and a yellow BUSY, so you can tell it from one you accepted
(`display.tentative_yellow`, default on).
- **Google's filters**: no all-day or 24-hour-plus events. Also skipped:
cancelled events, events marked free, invitations you declined.
- **On time**: the loop wakes at the next change, not on the next poll.
- **Live config**: the running service applies an edited config within a
minute; an edit that does not load is left out, and the log says why.
- **Resilient**: a failing source keeps its last good events; a bar that
cannot be reached is retried; drawings expire on the bar by themselves
if the service stops.
- **No secrets in files**: passwords and tokens come from the environment
or a command such as the macOS Keychain.
## Get started
```sh
uv tool install git+https://github.com/kurok/busybar-calendar
busybar-calendar init # a few questions: how to reach the bar, which calendar
busybar-calendar check # proves it works, or says what to fix
busybar-calendar install # runs it in the background from now on
```
`init` asks how to reach the bar (BUSY Cloud, Wi-Fi or USB) and which
calendar to read, keeps the passwords in the macOS Keychain (libsecret on
Linux), signs in so you can pick calendars, and writes
`~/.config/busybar-calendar/config.toml`. To write the config yourself,
start from [`config.example.toml`](config.example.toml).
## Everyday commands
| Command | |
| --- | --- |
| `busybar-calendar` | Where you stand: the first steps before setup, the status after. |
| `busybar-calendar status` | Is it running, what did it last log, what is on the bar now and next. |
| `busybar-calendar check` | The bar, its token and every calendar, with what to fix. |
| `busybar-calendar add` | Another calendar, by the same questions as `init`. |
| `busybar-calendar restart` | After a new password or a `[bar]` change; other config edits apply by themselves within a minute. |
| `busybar-calendar upcoming` | The events the bar will show, and when; tentative ones marked. |
| `busybar-calendar calendars` | What each CalDAV account has, with ids, for `include`. |
| `busybar-calendar replay` | Tomorrow's events on the bar, a minute each. |
| `busybar-calendar demo` | A sample reminder on the bar, to see the look. |
| `busybar-calendar uninstall` | Stop and remove the background service. |
`status` and `check` also take `--json`, for scripts. A newer version is
`uv tool upgrade busybar-calendar && busybar-calendar restart` - or, with
`busybar-calendar install --auto-update`, the service fetches it by itself
every time it starts or restarts.
## As a BUSY Bar gallery app
`app.py` follows the [community gallery](https://github.com/maxswinkels/busybar-apps)'s
conventions: one entrypoint, `--host` (default `10.0.4.20`, the bar over
USB), secrets from the environment only. It runs from a clone:
```sh
git clone https://github.com/kurok/busybar-calendar.git && cd busybar-calendar
pip install -r requirements.txt
python app.py # the config init wrote; a demo schedule before that
python app.py --host 192.168.1.50 --ics "https://example.com/work.ics"
CALDAV_PASSWORD=... python app.py --caldav https://caldav.icloud.com --user me@icloud.com
BUSYBAR_TOKEN=... python app.py --cloud --ics "https://example.com/work.ics"
```
`CALDAV_PASSWORD` is the calendar's password, `BUSYBAR_TOKEN` the bar's HTTP
API password or, with `--cloud`, a BUSY Cloud token. On macOS the first
start after installing the requirements takes a few seconds while the system
checks the new libraries; later starts take a fraction of a second.
## Settings
The per-calendar settings are the Google Calendar integration's:
| Google Calendar integration | `config.toml` |
| --- | --- |
| Event reminders: on/off | `reminder.enabled` |
| How long before the event | `reminder.minutes_before` |
| Sound | `reminder.sound` |
| Show event name | `reminder.show_name` |
| Status during events: on/off | `status.enabled` |
| Event name, or a status from the list | `status.show`: `name`, `meeting`, `on_call`, `dnd`, ... |
| Sound | `status.sound` |
| *(not in Google's)* Busy status for events you marked tentative | `status.tentative` (default on) |
| *(not in Google's)* Yellow calendar and BUSY for events you marked tentative | `display.tentative_yellow` (default on) |
They layer: `[defaults]` for every calendar, then per source, then per
calendar (`[sources.calendars."Work".reminder]`); a default set once is the
account-level setting BUSY Cloud does not have yet
([busybar-firmware#1083](https://github.com/busy-app/busybar-firmware/issues/1083)).
Last come the `[[titles]]` rules, which match event titles in any calendar:
```toml
[[titles]]
match = ["Focus time", "Commute*"] # * and ? are wildcards; case does not matter
reminder.enabled = false
status.enabled = false
```
`[display]` sets the look: the icons (`red_calendar` or the bar's own
`animation`), the word beside the countdown (`busy_label`, `busy_color`) and
whether events you marked tentative are drawn in yellow (`tentative_yellow`).
Every option, with its default, is in the
[configuration reference](docs/configuration.md).
## Documentation
- [Setup](docs/setup.md) - the bar, calendar providers, secrets, running as a service.
- [Troubleshooting](docs/troubleshooting.md) - what `check` and `status` say when something is wrong, and the fix.
- [Configuration reference](docs/configuration.md) - every option and its default, generated from the code.
- [Changelog](CHANGELOG.md) - what changed in each version.
- [How it works](docs/how-it-works.md) - the design, the API calls, and what was measured on the hardware.
## Development
```sh
uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check .
```
`scripts/capture_screens.py` regenerates `docs/images` from a real bar.
## License
MIT, except the red and yellow calendar icons in `src/busybar_calendar/icons.py`:
they are redrawn from Google's [Noto Emoji](https://github.com/googlefonts/noto-emoji)
📅 and stay under the SIL Open Font License 1.1
([LICENSES/noto-emoji-OFL.txt](LICENSES/noto-emoji-OFL.txt)).app.py
#!/usr/bin/env python3
"""CalDAV Calendar: meeting reminders and a red BUSY from any CalDAV or ICS calendar.
A countdown before each event, then the event's title with a countdown to its
end and a red BUSY while it runs; the bar is left alone between events.
CalDAV (iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...) or any ICS
link (Outlook "Publish a calendar", Google's secret iCal address).
python app.py # demo schedule, BUSY Bar over USB (10.0.4.20)
python app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python app.py --ics https://example.com/work.ics
python app.py --caldav https://caldav.icloud.com --user me@icloud.com --include Work
python app.py --config config.toml # every setting: see config.example.toml
# (without flags, the config that
# `busybar-calendar init` wrote is used)
python app.py --test # draw one frame of an event and exit
Secrets come from the environment, never the command line or this file:
CALDAV_PASSWORD the CalDAV password (iCloud and Fastmail need an app password)
BUSYBAR_TOKEN the bar's HTTP API password, if its Wi-Fi API asks for one;
with --cloud, a BUSY Cloud token with the "BUSY Bar" scope
The calendar is polled every 5 minutes. A 401 or 404 from it stops the app
with an error rather than retrying. Drawings go in at priority 91, above a
running BUSY session (90), the way the bar's own calendar events do; between
events nothing is drawn, so the bar is never held.
"""
# First of all: Ctrl-C before the app is up ends it quietly. The first start
# after an install can take seconds while the system checks the newly installed
# libraries; once running, every mode handles SIGINT itself and clears the bar.
import signal
import sys
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
import argparse
import asyncio
import os
from pathlib import Path
# The app's code lives in src/busybar_calendar, next to this file.
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
from busybar_calendar import cli, playback
from busybar_calendar.config import Config, ConfigError, find, load
APP = "busybar-calendar"
USB_HOST = "10.0.4.20"
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
p.add_argument("--host", help=f"the bar: ip[:port] (default {USB_HOST}, USB)")
p.add_argument("--cloud", action="store_true", help="reach the bar through BUSY Cloud")
p.add_argument("--ics", action="append", default=[], metavar="URL", help="an ICS feed")
p.add_argument("--caldav", metavar="URL", help="a CalDAV server or calendar URL")
p.add_argument("--user", help="CalDAV username, usually the email address")
p.add_argument(
"--include",
action="append",
default=[],
metavar="NAME_OR_ID",
help="use only these CalDAV calendars (* wildcards)",
)
p.add_argument(
"--exclude",
action="append",
default=[],
metavar="NAME_OR_ID",
help="leave these CalDAV calendars out",
)
p.add_argument("--minutes", type=int, default=5, help="reminder before an event (5)")
p.add_argument("--priority", type=int, default=91, help="draw priority (91)")
p.add_argument("--quiet", action="store_true", help="no sounds")
p.add_argument("--config", help="a TOML file with every setting")
p.add_argument("--demo", action="store_true", help="loop a made-up schedule")
p.add_argument("--test", action="store_true", help="draw one frame and exit")
p.add_argument("-v", "--verbose", action="store_true")
return p.parse_args(argv)
def config_file(args: argparse.Namespace) -> Path | None:
"""The config to use: --config, else - unless calendars are given as flags - the one
`busybar-calendar init` wrote ($BUSYBAR_CALENDAR_CONFIG, ./config.toml or
~/.config/busybar-calendar/config.toml), else none."""
if args.config:
return Path(args.config)
if args.ics or args.caldav:
return None
try:
return find()
except ConfigError:
return None
def build_config(args: argparse.Namespace, path: Path | None) -> Config:
if path is not None:
config = load(path)
if args.host:
config.bar.address = args.host
return config
bar: dict = {"application_name": APP, "priority": args.priority}
if not args.cloud:
bar["address"] = args.host or USB_HOST
if os.environ.get("BUSYBAR_TOKEN"):
bar["token_env"] = "BUSYBAR_TOKEN"
elif args.cloud:
raise ConfigError("--cloud needs a BUSY Cloud token in BUSYBAR_TOKEN")
sources = [
{"name": f"ics-{n}" if len(args.ics) > 1 else "ics", "type": "ics", "url": url}
for n, url in enumerate(args.ics, 1)
]
if args.caldav:
caldav = {
"name": "caldav",
"type": "caldav",
"url": args.caldav,
"include": args.include,
"exclude": args.exclude,
}
if args.user:
caldav |= {"username": args.user, "password_env": "CALDAV_PASSWORD"}
sources.append(caldav)
sound = not args.quiet
# --minutes 0 fails here, not at the first event
return Config.model_validate(
{
"bar": bar,
"defaults": {
"reminder": {"minutes_before": args.minutes, "sound": sound},
"status": {"sound": sound},
},
"sources": sources,
}
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
cli.setup_logging(args.verbose)
try:
path = config_file(args)
config = build_config(args, path)
if args.test:
return asyncio.run(playback.draw_test_frame(config))
if args.demo or not config.sources:
if not args.demo:
print(
"No calendar yet (--ics, --caldav, or a config from `busybar-calendar "
"init`): playing a demo schedule.",
file=sys.stderr,
)
return asyncio.run(playback.run_demo(config))
where = config.bar.address or "BUSY Cloud"
print(
f"{APP} -> {where}{f', config {path}' if path else ''} (Ctrl-C to stop)",
file=sys.stderr,
)
return asyncio.run(cli.run_service(config))
except cli.EXPECTED as error:
for line in cli.describe(error):
print(f"{APP}: {line}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())config.example.toml
# An example busybar-calendar config. `busybar-calendar init` writes one for you;
# every option, with its default, is in docs/configuration.md.
[bar]
# Leave out `address` to reach the bar through BUSY Cloud, with a token that has
# the "BUSY Bar" access scope. On your network: address = "192.168.1.50" and the
# bar's HTTP API password as the token.
token_command = ["security", "find-generic-password", "-s", "busybar-calendar-bar", "-w"]
[defaults.reminder]
minutes_before = 5
[defaults.status]
# Events you marked tentative ("maybe") show as busy too; false keeps only
# their reminder. They are drawn in yellow; [display] tentative_yellow = false
# turns that off.
tentative = true
# A CalDAV account: iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...
[[sources]]
name = "iCloud"
type = "caldav"
url = "https://caldav.icloud.com"
username = "me@icloud.com"
password_command = ["security", "find-generic-password", "-s", "busybar-calendar-icloud", "-w"]
# Which calendars: names or ids from `busybar-calendar calendars`, * wildcards.
include = ["Home", "Work"]
# One calendar shown as the bar's own "meeting" status, with an earlier reminder.
[sources.calendars.Work.reminder]
minutes_before = 10
[sources.calendars.Work.status]
show = "meeting"
# A published ICS / webcal link: Outlook's "Publish a calendar", Google's secret
# address in iCal format, a Proton share link.
[[sources]]
name = "Team"
type = "ics"
url = "webcal://outlook.office365.com/owa/calendar/.../calendar.ics"
[sources.reminder]
sound = false
# Settings by event title, from any calendar, on top of the calendar's own.
# `*` and `?` are wildcards; case does not matter.
[[titles]]
match = ["Focus time", "Commute*"]
reminder.enabled = false
status.enabled = false
[[titles]]
match = "Lunch*"
status.show = "lunch"docs/configuration.md
# Configuration reference
The config is a TOML file. `busybar-calendar init` writes one; it is found
in `~/.config/busybar-calendar/config.toml`, `./config.toml`, or wherever
`$BUSYBAR_CALENDAR_CONFIG` or `-c` points. Only `[[sources]]` is required;
everything else has the default shown.
Secrets - `token` and `password` - take one of three forms. Prefer the last
two, which keep the secret out of the file:
```toml
password = "..." # inline
password_env = "CALDAV_PASSWORD" # an environment variable
password_command = ["security", "find-generic-password", "-s", "my-calendar", "-w"]
```
The command is run without a shell, and its output is the secret.
*Generated from the code by `python -m busybar_calendar.reference`; edit the
descriptions in `config.py`, not this file.*
## `[bar]`
How to reach the bar.
| Key | Default | |
| --- | --- | --- |
| `address` | none | The bar on your network (`ip[:port]`; `10.0.4.20` over USB). Leave it out to reach the bar through BUSY Cloud. |
| `token` / `token_env` / `token_command` | none | With an address, the bar's HTTP API password (32 characters at most); without, a BUSY Cloud token with the "BUSY Bar" access scope. |
| `priority` | `91` | Draw priority: 91 is above a running BUSY session (90), like the bar's own calendar events. |
| `application_name` | `"busybar-calendar"` | The name drawings are made under; also the app's id in the community gallery. |
## `[sync]`
How the calendars are read.
| Key | Default | |
| --- | --- | --- |
| `poll_minutes` | `5` | How often calendars are read. |
| `lookahead_hours` | `24` | How far ahead events are read. |
| `skip_free` | `true` | Leave out events marked free (`TRANSP:TRANSPARENT`). |
| `timezone` | none | For event times that carry no timezone, e.g. `Europe/Kyiv`. Default: this machine's. |
## `[display]`
What the bar shows.
| Key | Default | |
| --- | --- | --- |
| `title_font` | `"small"` | `small` fits the title and countdown on two lines; `global` draws Cyrillic, CJK and accents, on one line beside the countdown. |
| `transliterate` | `true` | Fold titles to ASCII for the bitmap fonts (`Планування` -> `Planuvannya`). Not used with `global`. |
| `reminder_sound` | `"shared/sounds/calendar_reminder_ends.snd"` | The stock sound a reminder plays. |
| `event_sound` | `"shared/sounds/calendar_event_starts.snd"` | The stock sound an event's start plays. |
| `reminder_icon` | `"red_calendar"` | The icon before an event: the red calendar, or the bar's own animation. |
| `event_icon` | `"red_calendar"` | The icon during an event: the red calendar, or the bar's own animation. |
| `busy_label` | `"BUSY"` | The word beside the countdown during an event; `""` for none. Not drawn with the `global` font. |
| `busy_color` | `"#FF0000FF"` | Its colour, `#RRGGBB` or `#RRGGBBAA`. |
| `tentative_yellow` | `true` | Draw events you marked tentative in yellow - the calendar icon and the label - so they differ from accepted ones. Off, they look the same. The icon is yellow only as the `red_calendar`; the bar's own animation has no yellow version. |
## `[defaults.reminder]`
The countdown before an event: the settings the bar's Google Calendar integration has per calendar. They layer: `[defaults.reminder]`, then a source's `[sources.reminder]`, then one calendar's `[sources.calendars."<name or id>".reminder]`, then the `[[titles]]` rules that match the event's title.
| Key | Default | |
| --- | --- | --- |
| `enabled` | `true` | Show a reminder before events. |
| `minutes_before` | `5` | How long before the start it appears. |
| `sound` | `true` | Play the reminder sound as it appears. |
| `show_name` | `true` | Show the event's title; off, it says "Next event". |
## `[defaults.status]`
What the bar shows while an event is on. It layers the same way: `[defaults.status]`, `[sources.status]`, `[sources.calendars."<name or id>".status]`, `[[titles]]`.
| Key | Default | |
| --- | --- | --- |
| `enabled` | `true` | Show the event while it is on. |
| `show` | `"name"` | `name`: the red calendar, the title, a countdown and BUSY. Or one of the bar's own statuses: `back_soon`, `booked`, `chill_time`, `coding`, `dnd`, `flow`, `keep_out`, `low_social_battery`, `lunch`, `meeting`, `on_air`, `on_call`. |
| `sound` | `true` | Play the event sound as it starts. |
| `tentative` | `true` | Events you marked tentative show as busy too. Off, they keep their reminder only. |
## `[[sources]]`
One CalDAV account or one ICS link each; at least one.
| Key | Default | |
| --- | --- | --- |
| `name` | **required** | What logs and overrides call this source; unique. |
| `type` | **required** | `caldav` for an account, `ics` for a published link (one calendar). |
| `url` | **required** | CalDAV: the server, or one calendar's URL. ICS: the link; `webcal://` works. |
| `username` | none | Usually the email address. |
| `password` / `password_env` / `password_command` | none | iCloud and Fastmail need an app password. |
| `auth` | `"basic"` | HTTP authentication; a few servers want `digest`. |
| `emails` | `[]` | Your attendee address(es), to read your response to invitations. Default: the username, when it is an email address. |
| `include` | `[]` | CalDAV: only these calendars, by name, id or URL; `*` and `?` are wildcards. Default: all. The one listed last wins an overlap. |
| `exclude` | `[]` | CalDAV: leave these calendars out, after include. |
| `calendars` | `{}` | Per-calendar reminder and status overrides, keyed by name or id. |
## `[[titles]]`
Settings for the events whose title matches, from whichever calendar: a
rule's `reminder` and `status` go on top of the calendar's, and a later rule
on top of an earlier one. To keep some events off the bar, and show lunch as
the bar's own status:
```toml
[[titles]]
match = ["Focus time", "Commute*"]
reminder.enabled = false
status.enabled = false
[[titles]]
match = "Lunch*"
reminder.enabled = false
status.show = "lunch"
```
`busybar-calendar upcoming` shows what each event ends up with.
| Key | Default | |
| --- | --- | --- |
| `match` | **required** | The titles it applies to: one or a list; `*` and `?` are wildcards, and case does not matter. |docs/how-it-works.md
# How it works
## Why a service, and not an app on the bar
The bar's Google Calendar integration lives in BUSY Cloud, which is not
open source. Reading the firmware shows how it reaches the bar: the cloud
sends ordinary HTTP API requests through an MQTT proxy on the device
(`mqtt_http_proxy.c`). There is no calendar endpoint, on the bar or in the
cloud's device API. A Google event on the bar is made of the same requests
anyone can send:
- `POST /api/display/draw` with the stock calendar animations, a text and a
countdown element;
- `POST /api/audio/play` with the stock calendar sounds;
- `DELETE /api/display/draw` when it is over.
So anything that makes those requests gets the same events on screen. This
service does that, with a CalDAV account or an ICS feed in place of Google.
A JavaScript app on the bar cannot do the same thing. The firmware runs one
JS app at a time and stops it when you leave it, so it could not put a
reminder on top of a BUSY session or the clock the way the integration does.
```
CalDAV server ─┐
ICS feed ──────┤ busybar-calendar BUSY Bar
├─► sources ─► schedule ─► render ─► runner ─► HTTP API
(poll) │ (events) (what now) (elements) (draw, via BUSY Cloud
clear, (Bearer token)
sound) or the LAN
(X-API-Token)
```
## The pieces
| Module | Does |
| --- | --- |
| `caldav.py` | Discovery (`/.well-known/caldav`, `current-user-principal`, `calendar-home-set`) and `calendar-query` REPORTs with a time range. Hrefs are joined against the URL that answered, which is what makes iCloud's per-account hosts work. |
| `sources.py` | One class per source type. CalDAV calendars are listed again every 12 refreshes; ICS feeds use `If-None-Match`. |
| `events.py` | iCalendar to events: recurrence expanded with `recurring-ical-events`, times made timezone-aware, and the filters applied. |
| `schedule.py` | Pure logic: which screen is due now, and when that next changes. |
| `render.py` | The display elements for a screen, and its sound. |
| `runner.py` | The loop: refresh, decide, draw or clear, sleep until the next change. |
| `icons.py` | The red and yellow calendars, as XPM2 images drawn inline. |
| `config.py` | The TOML config, its models and where it is found; each option's description lives here. |
| `connect.py` | What every command shares: the bar client, the sources, stopping on a signal. |
| `diagnose.py` | `check` and `status`, as text or JSON. |
| `playback.py` | Showing events outside the schedule: `replay`, the demo, the gallery's test frame. |
| `wizard.py` | `init` and `add`: the questions, the secrets, the config they write. |
| `service.py` | `install`, `uninstall`, the service's state and log: launchd or systemd. |
| `reference.py` | Generates the configuration reference from `config.py`. |
| `cli.py` | The command line: the commands, their options, which code each runs. |
## Rules
Each event can open two screens. A **reminder** runs from `minutes_before`
until the start, and a **status** runs from the start to the end.
- When several screens are due at once:
1. **your response decides first**: an event you accepted beats one you
marked tentative, which beats one you have not answered. Events of
your own, with no attendees, count as accepted. Your response is your
own attendee entry's `PARTSTAT`, found by your address (`emails`, which
defaults to the CalDAV username), and it counts even on events you
organise. Published feeds leave the attendees out; there the event's
own marking counts instead, Outlook's `X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE`
or `STATUS:TENTATIVE`;
2. then the event starting latest, the Google integration's rule ("the
newest event and the most recently added calendar take priority"), so
a reminder for your next meeting replaces the status of the one still
running, as long as you are as sure to attend the next one;
3. then the calendar configured last.
- The response ranks whole screens, so it holds however two events are
placed against each other. With Maybe 10:00-11:00 and Sure 10:30-11:30
the bar shows Maybe, then Sure from its reminder at 10:25 until it
ends at 11:30 - Maybe's end at 11:00 changes nothing. With Sure
10:00-11:00 and Maybe 10:30-11:30, Maybe's reminder and start do not
cut in, and Maybe appears when Sure ends. A tentative event never takes
the bar from an accepted one that is showing something.
- An event you marked tentative puts the busy status up like any other,
unless `status.tentative = false`: then it keeps its reminder but never
shows as busy, and whatever else is on carries on.
- A tentative event is also drawn differently: a yellow calendar and a yellow
`BUSY`, in its reminder and its status alike, where an accepted or
unanswered one gets the red ones. `display.tentative_yellow = false`
draws them all in red. A calendar icon set to the bar's own `animation`
stays as it is (the stock animation has no yellow version) and only the
label turns yellow, and a stock status such as `meeting` is never recoloured.
Changing your answer on your phone redraws the screen at the next refresh.
- Like Google: events of 24 hours or more, and all-day events, are not
shown.
- Also skipped: `STATUS:CANCELLED`, `TRANSP:TRANSPARENT` (free) unless
`skip_free = false`, and invitations you declined or delegated.
- A sound plays when a screen opens, but not when the service starts in
the middle of one: restarting during a meeting does not chime again.
## Drawing
| Element | Reminder | Status (`show = "name"`) | Status (`show = "meeting"` etc.) |
| --- | --- | --- | --- |
| `cal_xpm` | the red calendar (default) | the red calendar (default) | |
| `cal_xpm_maybe` | the yellow calendar, for a tentative event | the yellow calendar, for a tentative event | |
| `cal_icon` | `shared/animations/calendar_reminder_16x16.anim` with `reminder_icon = "animation"` | `shared/animations/calendar_event_16x16.anim` with `event_icon = "animation"` | |
| `cal_title` | title, `small` font, scrolls if long | title | |
| `cal_time` | countdown to the start | countdown to the end | |
| `cal_busy` | | `BUSY` in red (yellow for a tentative event), right-aligned on the countdown's line | |
| `cal_status` | | | `shared/animations/<status>_72x16.anim` |
The calendar travels inside each drawing as an XPM2 `xpmbitmap`
element (the firmware takes up to 32 colours, full colour on the front
display), so nothing is uploaded to the bar. busylib 2.6 has no model for
that element, so drawings are posted with `api_request` rather than
`display_draw`. The icons are redrawn from Noto Emoji's 📅 at 16x16; see
`icons.py` for the pixels and their licence. The yellow one has an element id of its own
(`cal_xpm_maybe`), so a switch between the colours clears and redraws the
icon instead of depending on the bar swapping an existing bitmap's pixels.
Sounds: `shared/sounds/calendar_reminder_ends.snd` when a reminder opens and
`shared/sounds/calendar_event_starts.snd` when an event starts. Both can be
changed under `[display]`.
Drawings go in at priority 91. The firmware uses 10 for its built-in apps
and 90 for an active BUSY session, so like Google events these appear above
a running session. Every element carries `display_until`, so the bar takes
the drawing down by itself if the service stops.
## Measured on the hardware
The layout and timing were checked on a bar running firmware 1.2.4 / API
27.5.0, by drawing each screen and reading the pixels back with
`GET /api/screen` (the source of the pictures in the README). That turned
up things the API description does not say:
- **The countdown sits high in its box.** Countdown elements use the 7px
`superscript` font, which puts its digits 4px above the bottom of the
element. Anchored `bottom_left` at y=16, the digits landed on rows 7-11,
into the title's descenders. At y=19 they land on rows 10-14, level with
the icon's last row. Beside a `global`-font title, the countdown sits at
y=9 so both share rows 6-10.
- **A redraw at the moment of expiry loses its animation.** Redrawing an
element at the same instant its old `display_until` fires drops the
animated icon (0 of 256 icon pixels lit). With the old expiry even a few
seconds later, the redraw keeps it. So `display_until` is the screen's
end plus 60 seconds: the service redraws or clears on time anyway, and
the extra minute only matters if it has stopped.
- **A draw merges by element id.** It updates the ids it names and leaves
the others up, so drawing the `meeting` animation after a reminder would
leave the reminder's elements underneath. The service clears its own
drawing first only when an element would be left over, and otherwise
updates in place. A reminder turning into its event only adds BUSY, so
that change is seamless: sampling the screen across it, clearing first
caught a half-drawn icon (96 of 234 pixels lit) in 1 of 20 frames, and
updating in place showed the full icon in all 20.
- **The screen has one owner.** `DELETE /api/display/draw` with this
application's name, while another application is on screen, answers
`400 this application_name is currently not displaying anything`. The
service takes that as "already clear" rather than an error to retry.
- **Text is not ASCII-only.** The published schema limits `text` to
printable ASCII, but the firmware accepts any text, and the `global` font
(Lana Pixel 11) draws Cyrillic. The default stays the schema-conforming
`small` font with transliteration; `title_font = "global"` is opt-in.
- **LAN tokens are 32 characters at most**, and over Wi-Fi nothing but
`/api/version`, `/api/access` and `/api/transport` answers while the
HTTP API is disabled.
- Through BUSY Cloud, a reminder, its start and its end each reached the
bar within half a second of the event's time.
## Limits
- Changes are seen on the next poll (`poll_minutes`, default 5), and ICS
feeds are only as fresh as their publisher makes them.
- Only the front display is drawn on.
- The bar's own clock decides `display_until` and the countdowns. It keeps
network time, so this matters only if its clock is off.docs/setup.md
# Setup
The quick way:
```sh
uv tool install git+https://github.com/kurok/busybar-calendar
busybar-calendar init # answers in, config out: the bar, the calendar, the passwords
busybar-calendar check # proves it works, or says what to fix
busybar-calendar install # runs it in the background from now on
busybar-calendar status # and later: is it running, and what is on the bar next
```
The rest of this page is what those commands do for you, for when you
want to do it yourself, or need more than one calendar source. Every option
of the config is in the [configuration reference](configuration.md).
1. [Connect to the bar](#connect-to-the-bar)
2. [Add your calendars](#add-your-calendars)
3. [Keep secrets out of files](#keep-secrets-out-of-files)
4. [Check it](#check-it)
5. [Run it as a service](#run-it-as-a-service)
6. [Troubleshooting](troubleshooting.md)
## Connect to the bar
There are two ways to reach the bar. They draw exactly the same things.
### Through BUSY Cloud (simplest)
This is the route the bar's Google Calendar integration takes, and it works
from anywhere, with nothing to enable on the bar.
1. In your BUSY account, create an API token and choose **BUSY Bar** under
**Access scope**.
2. Store it (see [secrets](#keep-secrets-out-of-files)) and give it to
`[bar]` without an `address`:
```toml
[bar]
token_command = ["security", "find-generic-password", "-s", "busybar-token", "-w"]
```
It is sent as `Authorization: Bearer ...` to `https://api.busy.app/busybar`.
### On the local network
Faster, and does not depend on the cloud, but the bar's HTTP API over Wi-Fi
is **disabled by default**.
1. Connect the bar to your computer by USB and open `http://10.0.4.20`.
2. On the **Network** tab, in **HTTP API**, choose **Set password and
enable**. Keep the password to **32 characters or fewer**. The firmware
refuses longer ones.
3. Find the bar's address: **Settings -> Wi-Fi -> your network -> View IP
Address**.
```toml
[bar]
address = "192.168.1.50"
token_command = ["security", "find-generic-password", "-s", "busybar-lan", "-w"]
```
The password is sent as `X-API-Token`. To check the bar's mode without a
password: `curl http://<address>/api/access` returns `{"mode":"disabled"}`,
`"enabled"` or `"key"`. While it says `disabled`, every other request over
Wi-Fi gets `403 Forbidden`, whatever token you send. Over USB
(`address = "10.0.4.20"`) no password is needed at all.
## Add your calendars
Each `[[sources]]` entry is one CalDAV account or one ICS feed. A CalDAV
account uses all of its calendars unless you [filter them](#choosing-calendars).
`busybar-calendar add` adds one more by asking the same questions as `init`,
and appends it to your config without touching the rest, then restarts a
background service so it reads the new calendar at once.
A running service also follows edits you make by hand: they apply within a
minute. An edit that does not load leaves it on the config it has, and its
log says what is wrong; a change under `[bar]` waits for
`busybar-calendar restart`.
### CalDAV
| Provider | `url` | Password |
| --- | --- | --- |
| iCloud | `https://caldav.icloud.com` | an app-specific password (account.apple.com -> Sign-In and Security -> App-Specific Passwords) |
| Fastmail | `https://caldav.fastmail.com` | an app password (Settings -> Privacy & Security) |
| Nextcloud | `https://cloud.example.com/remote.php/dav` | your password, or an app password |
| PrivateEmail (Namecheap) | `https://dav.privateemail.com` | your mailbox password |
| Radicale, Baïkal, Synology, others | the server's root URL, or a calendar's URL | as the server wants it |
`username` is usually your full email address. A root URL is enough when
the server supports discovery (`/.well-known/caldav`); otherwise give the
URL of one calendar, and it is used as it is. Servers that only speak
Digest auth need `auth = "digest"`.
```toml
[[sources]]
name = "iCloud"
type = "caldav"
url = "https://caldav.icloud.com"
username = "me@icloud.com"
password_command = ["security", "find-generic-password", "-s", "busybar-icloud", "-w"]
include = ["Home", "Work"]
[sources.calendars.Work.status]
show = "meeting"
```
### Choosing calendars
`busybar-calendar calendars` lists each calendar on the account with its
**id** (the last part of its URL) and **name**, and marks the ones in use:
```
PrivateEmail (caldav): 22 calendars, 1 used
* 4711 Doe, Jane
5001 Ops Maintenances
5002 Infra Maintenances
6001 Office - Room 1 (6 people)
...
```
Two lists filter them. Each entry is a calendar's **name**, its **id**, or
its **URL**, and may use the wildcards `*` and `?`. Matching is exact and
case-sensitive.
| | |
| --- | --- |
| `include` | Use only these calendars. Without it, all of them. |
| `exclude` | Leave these out, after `include`. |
```toml
include = ["Doe, Jane"] # by name
include = ["4711"] # by id: survives a rename
include = ["Doe, Jane", "* Maintenances"]
exclude = ["Office - Room *", "Room *"] # everything except the meeting rooms
```
Organisation accounts often expose shared calendars. A PrivateEmail
business mailbox, for example, lists every meeting room and team calendar
next to your own. With no filter, other people's room bookings would reach
your bar, so name your own calendar under `include`.
Calendars come in the order `include` lists them: when two events start at
the same time, the calendar listed last wins. An entry that matches nothing
is reported, by `calendars` and in the service's log.
Per-calendar settings can be keyed by name or by id, and the id wins when
both are given:
```toml
[sources.calendars."4711".reminder]
minutes_before = 10
```
Your answer to each invitation is read from your attendee entry, found by
your address: declined invitations are skipped, and when two events overlap,
the one you accepted wins over one you marked tentative or have not
answered - whether they start together or one is shifted against the other.
An event you marked tentative ("Maybe") is drawn with a yellow calendar and
a yellow BUSY instead of the red ones. The address defaults to `username` when that is an email address;
set `emails = [...]` if your invitations go to another one.
### ICS / webcal feeds
For calendars that only publish a link. A feed is one calendar, named after
its source. `webcal://` links work as they are.
| Calendar | Where to find the link |
| --- | --- |
| Outlook / Microsoft 365 | Settings -> Calendar -> Shared calendars -> **Publish a calendar** -> ICS link |
| Google Calendar | Settings -> the calendar -> Integrate calendar -> **Secret address in iCal format** |
| Proton Calendar | the calendar's menu -> Share -> **Share with anyone** (link) |
```toml
[[sources]]
name = "Outlook"
type = "ics"
url = "https://outlook.office365.com/owa/calendar/.../calendar.ics"
```
A published feed leaves out who is invited, so your own answer to an invitation
is not in it; an event the feed marks tentative (Outlook does, as its busy
status) still counts as tentative. Outlook's Windows timezone names are
understood. A feed is only as fresh as the provider makes it. Outlook and Google can
take a while to republish a change, while CalDAV sees it on the next poll
(`poll_minutes`, default 5).
## Keep secrets out of files
Every password and token takes one of three forms. Prefer the last two:
```toml
password = "..." # inline: only for throwaway testing
password_env = "ICLOUD_APP_PASSWORD" # an environment variable
password_command = ["security", "find-generic-password", "-s", "busybar-icloud", "-w"]
```
The same goes for the bar: `token`, `token_env`, `token_command`. A command
is run without a shell and its output, stripped, is the secret.
On macOS, store a secret in the Keychain from your own terminal. It prompts,
so the secret ends up neither on screen nor in your shell history:
```sh
security add-generic-password -U -s busybar-icloud -a me@icloud.com -w
```
On Linux, `secret-tool` (libsecret) or `pass` work the same way:
`password_command = ["secret-tool", "lookup", "service", "busybar-icloud"]`.
`config.toml` is in `.gitignore`. Keep it that way even with no secrets in
it, since it names your accounts.
## Check it
```sh
busybar-calendar check
```
checks the whole setup and says what to fix:
```
Bar
ok BUSY Cloud: firmware 1.2.4, API 27.5.0, token accepted
Calendars
ok iCloud: signed in; 2 of 5 calendars used: Home, Work
ok iCloud: 23 events in the next 7 days; next Tue 13 Oct 09:00 Standup
All good.
```
A refused bar call comes with its cause: the Wi-Fi HTTP API switched off,
a missing password, or a cloud token given as a LAN password. The other
commands look closer:
```sh
busybar-calendar calendars # discovery: calendars per CalDAV source
busybar-calendar upcoming --hours 48 # events, settings, your response, what is on now
busybar-calendar demo # a sample reminder for 20 seconds
busybar-calendar demo --status --show meeting
busybar-calendar demo --font global --title "Планування спринту"
busybar-calendar replay # tomorrow's real events, one a minute
busybar-calendar replay --day today --seconds 30 --quiet
busybar-calendar -v run # run in the foreground with debug logs
```
## Run it as a service
```sh
busybar-calendar install
```
checks the setup, then makes it run in the background from now on: at
login, and again if it stops. On macOS that is a launch agent
(`~/Library/LaunchAgents/app.busybar.calendar.plist`, logging to
`~/Library/Logs/busybar-calendar.log`); on Linux a systemd user unit
(`~/.config/systemd/user/busybar-calendar.service`, logs in
`journalctl --user -u busybar-calendar`).
- It runs from the environment you installed from (a `uv tool` or a
virtualenv) and reads the config `install` found, wherever you start it.
- Restarts are five minutes apart, so a calendar that refuses your password
is not asked again every few seconds.
- A launch agent runs in your login session, so it can read the login
Keychain for `token_command` / `password_command`.
- `--dry-run` shows the file and the commands without changing anything;
`--force` installs even when `check` finds a problem.
- `--auto-update` makes it [update itself](#updating-by-itself) every time
it starts.
- `busybar-calendar uninstall` stops and removes it.
`busybar-calendar status` shows how it is doing:
```
busybar-calendar 0.3.1
Service running 0.3.1, pid 4321
log /Users/me/Library/Logs/busybar-calendar.log
16:40:19 INFO busybar_calendar.runner: reminder: Design review (Work, accepted) ...
16:45:00 INFO busybar_calendar.runner: status: Design review (Work, accepted) ...
Bar BUSY Cloud: answering
Calendar now status: Design review, until Tue 13 Oct 17:30 (in 44m)
next reminder: Retro, at Tue 13 Oct 17:55 (in 1h 09m)
```
A stopped service shows its last exit code, with the log lines that explain it.
Both `status` and `check` take `--json`, for scripts, dashboards or a menu-bar
widget. `check --json` exits non-zero when something fails, like `check`:
```sh
busybar-calendar status --json | jq -r '.calendar.next | "\(.kind) \(.title) at \(.at)"'
busybar-calendar check --json | jq '.findings[] | select(.ok | not) | .message'
```
### Upgrading
Upgrade the install, then restart the service, which keeps running the old
version until then:
```sh
uv tool upgrade busybar-calendar # fetches the newest from git, for the command and the service
busybar-calendar restart
```
That works for the one-line install from git. An install from a clone takes
`git pull && uv tool install --force .`, and one from a release file or a
tag (`...@v0.3.1`) takes `uv tool install --force` with the new one. Your
config and passwords stay where they are.
`status` shows which version the service runs, and says when it is not the
one just installed:
```
Service running 0.3.0, pid 4321
that is 0.3.0, but this is 0.3.1: `busybar-calendar restart` runs this one
```
When the command you run is from another install than the service (a
checkout run with `uv run`, say), a restart would start the old version
again, so `status` names the install to upgrade instead:
```
that is 0.3.0, from ~/.local/share/uv/tools/busybar-calendar, not this 0.3.1:
`uv tool upgrade busybar-calendar`, then `busybar-calendar restart`
```
It gives the `uv tool upgrade` command when that install is a uv tool from
git, and says "upgrade that one" otherwise.
#### Updating by itself
```sh
busybar-calendar install --auto-update
```
installs the service so that every start - at login, after a restart, or
after it stopped - first runs `uv tool upgrade busybar-calendar`, then
starts the newest version. It comes before the config is read, so a config
that uses something new gets the version that understands it.
- It needs the one-line install from git (`uv tool install
git+https://github.com/kurok/busybar-calendar`): that is the install
`uv tool upgrade` moves forward, to the newest commit on `main`. `install`
refuses `--auto-update` for any other install, and when there is no `uv`.
- If the update fails - no network yet at login, say - the service starts
the version it has, and tries again at its next start. Each start logs
what happened:
```
INFO busybar_calendar.update: auto-update: 0.5.1 (a0c7935) -> 0.6.1 (0046d3c)
INFO busybar_calendar: busybar-calendar 0.6.1 started: 1 calendar source(s), bar via BUSY Cloud
```
- So `busybar-calendar restart` is also how to update now, and `status`
says so: `Service running 0.6.1, pid 4321, updates itself at each start`.
- `install` without the flag turns it off again.
### Docker
```sh
docker build -t busybar-calendar .
docker run -d --restart unless-stopped \
-e BUSYBAR_TOKEN -e CALDAV_PASSWORD \
-v "$PWD/config.toml:/config/config.toml:ro" busybar-calendar
```
In a container, use `token_env` and `password_env`, since the Keychain is
not there.
## Troubleshooting
See [troubleshooting.md](troubleshooting.md): what `check` and `status` say
for each common problem, and what to do about it.docs/troubleshooting.md
# Troubleshooting
Two commands answer most questions:
```sh
busybar-calendar check # is the setup right? the bar, its token, every calendar
busybar-calendar status # is the service running, what did it last log, what is on the bar?
```
The outputs below are the commands' own, from real failures.
## The bar refuses: `refused (403)`
`check` names the cause after the code.
**The bar's HTTP API is off over Wi-Fi.** It is off until you switch it on:
```
Bar
FAIL 192.168.1.50: refused (403). The bar's HTTP API over Wi-Fi is disabled: enable it at http://10.0.4.20 (USB) -> Network -> HTTP API, or use a BUSY Cloud token without an address.
```
Connect the bar by USB, open `http://10.0.4.20`, then **Network -> HTTP API
-> Set password and enable**. Or leave `address` out of `[bar]` and give a
BUSY Cloud token instead: that needs nothing switched on.
**The cloud token is wrong, revoked, or lacks the scope:**
```
Bar
FAIL BUSY Cloud: refused (403). The cloud token is wrong, revoked, or lacks the BUSY Bar access scope.
```
Create a new token in your BUSY account with **BUSY Bar** under **Access
scope**, and store it where `token_command` / `token_env` reads it.
**A cloud token given as the bar's password.** With `address` set, the token
must be the HTTP API password, 32 characters at most; `check` spots a cloud
token by its length (`The token is 43 characters ... Is it a cloud token?`).
Leave `address` out to use the cloud token as one.
## The bar is unreachable
```
FAIL 192.168.1.50: unreachable (...)
```
The address is wrong, or the bar is off or on another network: the computer
must be on the bar's network for a LAN address (**Settings -> Wi-Fi -> your
network -> View IP Address** on the bar). Over USB the bar is always
`10.0.4.20`. BUSY Cloud works from anywhere.
## The bar stopped answering
`status` checks the bar too:
```
Bar BUSY Cloud: NOT answering (no answer). Is it on and online? If it stays stuck, unplug it and plug it back in.
```
A bar that answers ping but not its HTTP API - not on the LAN, not through
BUSY Cloud - has a stuck HTTP server; the service cannot draw until it is
power-cycled, and carries on by itself once the bar is back. (Seen on
firmware 1.2.4 after a draw request with a title of thousands of
characters; the service now caps titles at 120.)
## A calendar refuses the password
```
Calendars
FAIL iCloud: PROPFIND https://caldav.icloud.com/: 401, check the credentials
```
iCloud and Fastmail need an **app password**, not the account's own. Store
the new one under the same Keychain entry, then restart the service, which
reads passwords only when it starts:
```sh
security add-generic-password -U -s busybar-calendar-me@icloud.com -a me@icloud.com -w
busybar-calendar restart
```
A running service stops on a refused password rather than asking again and
again, and `status` shows it:
```
Service stopped (last exit code 1); the log says why
log /Users/me/Library/Logs/busybar-calendar.log
busybar-calendar: iCloud: PROPFIND https://caldav.icloud.com/: 401, check the credentials
```
It is started again five minutes later, so fixing the password is enough;
`busybar-calendar restart` does not wait.
## `no calendar matches`
```
Calendars
FAIL Work: signed in; 0 of 2 calendars used: none
FAIL Work: no calendar matches 'Wrok'; there are: Team, Work
```
An `include` or `exclude` entry names no calendar. Names are exact and
case-sensitive; `busybar-calendar calendars` lists them with their ids, and
an id keeps working when a calendar is renamed.
## `Config does not load`
```
Config does not load: /Users/me/.config/busybar-calendar/config.toml
the running service keeps the config it last loaded
titles.0.status: show: show must be 'name' or one of back_soon, booked, ...
```
An edit broke the config. The service picks up edits by itself within a
minute, but not this one: it carries on with the config it last loaded,
and its log has the same lines. Each says where the problem is -
`titles.0` is the first `[[titles]]` rule, `sources.1` the second source -
and once the file is fixed, the service takes it at its next wake.
## Nothing on the bar during a meeting
`busybar-calendar status` says what the service thinks is on the bar:
- **`nothing on the bar now`**: the event is not one it shows. `busybar-calendar
upcoming` lists every event it will show, with your response. Events are
left out when they are all-day or 24 hours or longer, cancelled, marked
free (`skip_free`), declined, in a calendar that `include` / `exclude`
leaves out, or tentative with `status.tentative = false`.
- **`now status: ...` but the bar shows something else**: something above
`bar.priority` (91) holds the screen, such as a menu you opened on the bar.
The calendar comes back when it lets go; the log shows `bar refused the
update (...)` meanwhile.
## A reminder came late, or not at all
Calendars are read every `poll_minutes` (5): an event added two minutes
before it starts can miss its reminder. ICS links are only as fresh as their
publisher makes them; Outlook and Google can take hours to republish.
## Getting more detail
```sh
busybar-calendar -v run # the service in the foreground, with debug logs
busybar-calendar check --json # the findings as data, to attach to a report
busybar-calendar status --json
```manifest.yaml
name: CalDAV Calendar
author: kurok
description: Meeting reminders with a countdown, then a red BUSY while it runs, from any CalDAV or ICS calendar - iCloud, Fastmail, Nextcloud, PrivateEmail or an Outlook link.
tags:
- calendar
- caldav
- ics
- productivity
- info
preview: ./preview.gif
repo: https://github.com/kurok/busybar-calendarpyproject.toml
[project]
name = "busybar-calendar"
version = "0.8.0"
description = "CalDAV and ICS calendar reminders for the BUSY Bar, drawn the way its Google Calendar integration draws them"
readme = "README.md"
license = "MIT AND OFL-1.1"
license-files = ["LICENSE", "LICENSES/*"]
requires-python = ">=3.11"
dependencies = [
"busylib>=2.6,<3",
"httpx>=0.27",
"icalendar>=6",
"recurring-ical-events>=3.3",
"defusedxml>=0.7",
"anyascii>=0.3",
]
[project.scripts]
busybar-calendar = "busybar_calendar.cli:main"
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
[tool.ruff.lint.per-file-ignores]
# Non-Latin event titles are the point of these tests.
"tests/*" = ["RUF001"]
# The SIGINT handler has to be in place before the first slow import.
"app.py" = ["E402", "I001"]requirements.txt
# busylib: Flipper's official BUSY Bar client (pre-approved) - LAN and BUSY Cloud.
busylib>=2.6,<3
# The calendar side, which the standard library cannot do safely or correctly:
# httpx speaks CalDAV's PROPFIND/REPORT with Basic and Digest auth,
httpx>=0.27
# icalendar parses the events and recurring-ical-events expands RRULE/EXDATE
# and moved occurrences in their own timezones,
icalendar>=6
recurring-ical-events>=3.3
# defusedxml parses the server's XML without entity expansion attacks,
defusedxml>=0.7
# anyascii turns titles in other scripts into text the bar's bitmap fonts draw.
anyascii>=0.3scripts/capture_screens.py
"""
Regenerate the screenshots in docs/images from a real bar.
Draws each layout on the bar named in a busybar-calendar config, reads the
front display back through GET /api/screen, and saves it as LED-style PNGs,
plus GIFs for the moving ones. Sounds stay off. Needs Pillow:
uv run --with pillow python scripts/capture_screens.py -c config.toml docs/images
With the service running, a capture takes its screen down: the drawing goes up
over whatever the service has on the bar - under another application name too,
the firmware swaps the canvas - and what it replaces does not come back by
itself, as the service only redraws at its next change. Stop the service first,
or touch its config file afterwards, which makes it redraw at once.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import itertools
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from PIL import Image, ImageDraw
from busybar_calendar import render
from busybar_calendar.config import DisplayConfig, ReminderSettings, StatusSettings, load
from busybar_calendar.connect import make_bar
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.schedule import Screen
W, H = 72, 16
CELL, DOT, PAD = 10, 8, 16
BACKGROUND, UNLIT = (12, 12, 12), (28, 28, 28)
def led(rgb: bytes) -> Image.Image:
"""A front-display frame as the bar shows it: square LEDs on a dark panel."""
image = Image.new("RGB", (W * CELL + 2 * PAD, H * CELL + 2 * PAD), BACKGROUND)
draw = ImageDraw.Draw(image)
for y in range(H):
for x in range(W):
i = (y * W + x) * 3
colour = tuple(rgb[i : i + 3])
if max(colour) < 20:
colour = UNLIT
left, top = PAD + x * CELL, PAD + y * CELL
draw.rounded_rectangle((left, top, left + DOT, top + DOT), radius=2, fill=colour)
return image
def scenarios(now: datetime):
"""(file name, screen, display settings, frames: 1 for a PNG, more for a GIF)."""
ref = CalendarRef("Work", "Work", 0)
def event(title: str, start_in: int, length: int = 30, response: str = "accepted") -> Event:
start = now + timedelta(minutes=start_in)
return Event("doc", title, start, start + timedelta(minutes=length), ref, response)
reminder, status, small = ReminderSettings(), StatusSettings(), DisplayConfig()
meeting = StatusSettings(show="meeting")
hidden = ReminderSettings(show_name=False)
cyrillic = event("Планування спринту", 5)
yield "reminder", Screen("reminder", event("Design review", 5), reminder, status), small, 18
yield "status", Screen("status", event("Design review", -2, 45), reminder, status), small, 1
maybe = event("Design review", -2, 45, "tentative")
yield "status-tentative", Screen("status", maybe, reminder, status), small, 1
in_progress = event("Design review", -2, 45)
yield "status-meeting", Screen("status", in_progress, reminder, meeting), small, 18
long_title = event("Quarterly planning with the design team", 10)
yield "long-title", Screen("reminder", long_title, reminder, status), small, 24
global_font = DisplayConfig(title_font="global")
yield "cyrillic-global", Screen("reminder", cyrillic, reminder, status), global_font, 24
yield "cyrillic-transliterated", Screen("reminder", cyrillic, reminder, status), small, 24
yield "hidden-name", Screen("reminder", event("Design review", 5), hidden, status), small, 1
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("-c", "--config", default="config.toml")
parser.add_argument("out", type=Path)
parser.add_argument("only", nargs="*", help="capture only these names")
args = parser.parse_args()
config = load(args.config)
app = config.bar.application_name
args.out.mkdir(parents=True, exist_ok=True)
bar = make_bar(config)
try:
for name, screen, display, count in scenarios(datetime.now(UTC)):
if args.only and name not in args.only:
continue
with contextlib.suppress(Exception): # 400 when nothing of ours is up
await bar.display_clear(application_name=app)
await render.send(bar, render.drawing(screen, display, app, config.bar.priority))
await asyncio.sleep(2.5) # let the cloud deliver it and the scroll begin
frames, stamps = [], []
for _ in range(count):
frames.append(led(await bar.screen(0)))
stamps.append(time.monotonic())
if count > 1:
gaps = [int((b - a) * 1000) for a, b in itertools.pairwise(stamps)]
frames[0].save(
args.out / f"{name}.gif",
save_all=True,
append_images=frames[1:],
duration=[*gaps, gaps[-1]],
loop=0,
optimize=True,
)
frames[len(frames) // 2].save(args.out / f"{name}.png", optimize=True)
print(f"{name}: {count} frame(s)")
finally:
with contextlib.suppress(Exception):
await bar.display_clear(application_name=app)
await bar.aclose()
if __name__ == "__main__":
asyncio.run(main())src/busybar_calendar/__init__.py
"""CalDAV and ICS calendar reminders for the BUSY Bar."""
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("busybar-calendar")
except PackageNotFoundError: # run from a checkout without installing, as app.py does
__version__ = "unknown"src/busybar_calendar/caldav.py
"""
A small, read-only CalDAV client: find the calendars, read events in a range.
Only the parts of RFC 4791 a reminder needs: discovery through
current-user-principal and calendar-home-set (RFC 5397, RFC 6764's
/.well-known/caldav as a fallback), and calendar-query REPORTs with a time
range. Recurrence is expanded by the caller, not the server.
Hrefs are joined against the URL that answered, which is what makes iCloud
work: its principal and calendars live on a per-account host, returned as
absolute URLs.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import unquote, urljoin, urlsplit
from xml.etree.ElementTree import Element
import httpx
from defusedxml.ElementTree import fromstring
logger = logging.getLogger(__name__)
DAV = "DAV:"
CALDAV = "urn:ietf:params:xml:ns:caldav"
APPLE = "http://apple.com/ns/ical/"
NS = {"d": DAV, "c": CALDAV, "a": APPLE}
_PROPFIND_ENTRY = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:current-user-principal/><d:resourcetype/><d:displayname/></d:prop>
</d:propfind>"""
_PROPFIND_HOME = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-home-set/></d:prop>
</d:propfind>"""
_PROPFIND_CALENDARS = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype/><d:displayname/><c:supported-calendar-component-set/>
</d:prop>
</d:propfind>"""
_REPORT_RANGE = """<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-data/></d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="{start}" end="{end}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>"""
MAX_REDIRECTS = 5
class CalDAVError(Exception):
def __init__(self, message: str, status: int | None = None) -> None:
super().__init__(message)
self.status = status
class CalDAVAuthError(CalDAVError):
pass
@dataclass(frozen=True)
class RemoteCalendar:
url: str
name: str
@property
def id(self) -> str:
"""The calendar's own path segment: 4711 in .../calendars/me@example.com/4711/."""
return unquote(urlsplit(self.url).path.rstrip("/").rsplit("/", 1)[-1])
@dataclass(frozen=True)
class _Response:
href: str
props: Element
def _stamp(value: datetime) -> str:
return value.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
class CalDAVClient:
def __init__(self, http: httpx.AsyncClient, url: str) -> None:
self._http = http
self._url = url
async def _send(self, method: str, url: str, body: str, depth: str) -> httpx.Response:
"""
Send a DAV request, following redirects with the method and body intact.
httpx, like browsers, turns a 302 into a GET; a PROPFIND that becomes
a GET is refused. /.well-known/caldav is usually a 302 (RFC 6764).
"""
headers = {"Depth": depth, "Content-Type": "application/xml; charset=utf-8"}
for _ in range(MAX_REDIRECTS + 1):
response = await self._http.request(
method, url, content=body.encode(), headers=headers, follow_redirects=False
)
location = response.headers.get("Location")
if response.status_code not in (301, 302, 303, 307, 308) or not location:
return response
url = urljoin(str(response.url), location)
raise CalDAVError(f"{method} {url}: more than {MAX_REDIRECTS} redirects")
async def _dav(self, method: str, url: str, body: str, depth: str) -> list[_Response]:
response = await self._send(method, url, body, depth)
code = response.status_code
if code in (401, 403):
raise CalDAVAuthError(f"{method} {url}: {code}, check the credentials", code)
if code != 207:
raise CalDAVError(f"{method} {url}: expected 207, got {code}", code)
base = str(response.url)
root = fromstring(response.content)
results = []
for item in root.findall("d:response", NS):
href = item.findtext("d:href", default="", namespaces=NS).strip()
for propstat in item.findall("d:propstat", NS):
status = propstat.findtext("d:status", default="", namespaces=NS)
prop = propstat.find("d:prop", NS)
if " 200 " in f"{status} " and prop is not None:
results.append(_Response(urljoin(base, href), prop))
return results
async def _href(self, url: str, body: str, path: str) -> str | None:
for response in await self._dav("PROPFIND", url, body, "0"):
href = response.props.findtext(path, default="", namespaces=NS).strip()
if href:
return urljoin(response.href, href)
return None
async def _principal(self, url: str) -> tuple[str | None, list[_Response]]:
try:
entry = await self._dav("PROPFIND", url, _PROPFIND_ENTRY, "0")
except CalDAVAuthError:
raise
except CalDAVError as error:
logger.debug("%s", error)
return None, []
for response in entry:
href = response.props.findtext(
"d:current-user-principal/d:href", default="", namespaces=NS
).strip()
if href:
return urljoin(response.href, href), entry
return None, entry
async def discover(self) -> list[RemoteCalendar]:
"""The event calendars behind the configured URL."""
principal, entry = await self._principal(self._url)
# A calendar URL given directly is used as it is.
for response in entry:
if response.props.find("d:resourcetype/c:calendar", NS) is not None:
name = response.props.findtext("d:displayname", default="", namespaces=NS)
return [RemoteCalendar(response.href, name.strip() or response.href)]
if principal is None:
parts = urlsplit(self._url)
well_known = f"{parts.scheme}://{parts.netloc}/.well-known/caldav"
if self._url.rstrip("/") != well_known:
principal, _ = await self._principal(well_known)
if principal is None:
raise CalDAVError(f"{self._url}: no current-user-principal; give a calendar URL")
home = await self._href(principal, _PROPFIND_HOME, "c:calendar-home-set/d:href")
if home is None:
raise CalDAVError(f"{principal}: no calendar-home-set")
calendars = []
for response in await self._dav("PROPFIND", home, _PROPFIND_CALENDARS, "1"):
if response.props.find("d:resourcetype/c:calendar", NS) is None:
continue
components = response.props.findall("c:supported-calendar-component-set/c:comp", NS)
if components and not any(c.get("name") == "VEVENT" for c in components):
continue # a task list or a journal
name = response.props.findtext("d:displayname", default="", namespaces=NS).strip()
calendars.append(RemoteCalendar(response.href, name or response.href))
return calendars
async def events(self, calendar: RemoteCalendar, start: datetime, end: datetime) -> list[str]:
"""The iCalendar data of every event resource overlapping the range."""
body = _REPORT_RANGE.format(start=_stamp(start), end=_stamp(end))
documents = []
for response in await self._dav("REPORT", calendar.url, body, "1"):
data = response.props.findtext("c:calendar-data", default="", namespaces=NS)
if data.strip():
documents.append(data)
return documentssrc/busybar_calendar/cli.py
"""Command line: the commands, their options, and which code each one runs."""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import shlex
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
import httpx
from pydantic import ValidationError
from . import __version__, connect, diagnose, playback, schedule, service, sources, update, wizard
from . import config as config_module
from .caldav import CalDAVError
from .config import Config, ConfigError
from .events import Event
from .runner import Runner
logger = logging.getLogger("busybar_calendar")
def _stamp(path: Path) -> tuple[int, int] | None:
try:
stat = path.stat()
except OSError:
return None
return stat.st_mtime_ns, stat.st_size
class ConfigWatch:
"""
Puts an edited config to work in a running service, within a minute.
An edit that does not load leaves the service on the config it has, and
says why in the log. `[bar]` is read only at start: a change there waits
for `busybar-calendar restart`.
"""
def __init__(self, path: Path, runner: Runner) -> None:
self.path = path
self.runner = runner
self._stamp = _stamp(path)
async def __call__(self) -> None:
stamp = _stamp(self.path)
if stamp == self._stamp:
return
self._stamp = stamp
running = self.runner.config
try:
config = config_module.load(self.path)
items = connect.make_sources(config)
except EXPECTED as error:
logger.warning(
"%s changed, but the service keeps the config it has: %s",
self.path,
"; ".join(describe(error)),
)
return
if config.bar != running.bar:
logger.warning("[bar] changed: `busybar-calendar restart` puts it to use")
config = config.model_copy(update={"bar": running.bar})
retired = self.runner.sources
self.runner.reconfigure(config, items)
await connect.close_all(retired)
logger.info("%s changed: reloaded, %d calendar source(s)", self.path, len(items))
async def run_service(config: Config, path: Path | None = None) -> int:
"""
Keep the bar in step with the calendars until a signal, or a source that
cannot work; with the config's `path`, follow its edits too.
"""
items = connect.make_sources(config)
bar = connect.make_bar(config)
stop = connect.stop_on_signals()
# `status` reads this line to tell which version the service runs.
logger.info(
"busybar-calendar %s started: %d calendar source(s), bar %s",
__version__,
len(items),
config.bar.address or "via BUSY Cloud",
)
runner = Runner(config, bar, items)
try:
await runner.run(stop, ConfigWatch(path, runner) if path is not None else None)
except sources.FatalSourceError as error:
print(f"busybar-calendar: {error}", file=sys.stderr)
return 1
finally:
await connect.close_all(runner.sources)
await bar.aclose()
return 0
async def cmd_run(config: Config, args: argparse.Namespace) -> int:
return await run_service(config, args.config_path)
async def cmd_check(config: Config, args: argparse.Namespace) -> int:
return await diagnose.check(config, as_json=args.json)
async def cmd_replay(config: Config, args: argparse.Namespace) -> int:
return await playback.replay(config, args.day, args.seconds, args.quiet)
async def cmd_demo(config: Config, args: argparse.Namespace) -> int:
return await playback.demo(config, args)
async def cmd_install(config: Config, args: argparse.Namespace) -> int:
"""Run the service in the background from now on: at login, restarted if it stops."""
plan = service.install_plan(args.config_path, auto_update=args.auto_update)
if args.dry_run:
print(f"Would write {plan.path}:\n")
print(plan.content.decode())
print("Would run:")
for command in [*plan.cleanup, *plan.commands]:
print(" " + shlex.join(command))
return 0
if not args.force:
print("Checking the setup first.\n")
if await diagnose.check(config):
print("Fix the problems above, or install anyway with --force.")
return 1
print()
service.install(plan)
print(f"Installed {plan.path}.")
print("It runs at login, comes back if it stops, and reads", args.config_path.resolve())
if args.auto_update:
print(f"At every start and restart it first runs `{' '.join(update.upgrade_command())}`.")
if plan.log:
print(f"Log: {plan.log}")
print("Remove it with: busybar-calendar uninstall")
return 0
def cmd_restart(_args: argparse.Namespace) -> int:
"""Restart the background service, after a password or the config changed."""
if service.restart():
print("Restarted. `busybar-calendar status` shows how it is doing.")
return 0
print("Not installed: `busybar-calendar install` runs it in the background.")
return 1
def cmd_uninstall(args: argparse.Namespace) -> int:
plan = service.uninstall_plan()
if args.dry_run:
print(f"Would remove {plan.path} and run:")
for command in [*plan.cleanup, *plan.commands]:
print(" " + shlex.join(command))
return 0
if service.uninstall(plan):
print(f"Stopped and removed {plan.path}.")
else:
print("Not installed.")
return 0
async def cmd_calendars(config: Config, _args: argparse.Namespace) -> int:
items = connect.make_sources(config)
try:
for item in items:
if isinstance(item, sources.CalDAVSource):
found = await item.calendars()
used, unmatched = sources.select(found, item.config.include, item.config.exclude)
print(f"{item.name} (caldav): {len(found)} calendars, {len(used)} used")
width = max((len(calendar.id) for calendar in found), default=0)
for calendar in found:
mark = "*" if calendar in used else " "
print(f" {mark} {calendar.id:<{width}} {calendar.name}")
for pattern in unmatched:
print(f" ! no calendar matches {pattern!r}")
else:
print(f"{item.name} (ics): one calendar, {item.config.url}")
finally:
await connect.close_all(items)
print("\n* = used. Filter with include/exclude by id, name or URL; * and ? are wildcards.")
return 0
def upcoming_notes(config: Config, event: Event) -> str:
"""What the bar does with an event, and why: its settings, your answer, its rules."""
ref = event.calendar
reminder, status = config.settings_for(ref.source, ref.name, ref.id, event.title)
notes = []
if reminder.enabled:
notes.append(f"reminder {reminder.minutes_before}m")
if schedule.shows_status(event, status):
notes.append(f"status {status.show}")
notes = notes or ["hidden"]
if event.response != "accepted":
notes.append(event.response)
if patterns := config.title_patterns(event.title):
notes.append("titles " + " ".join(map(repr, patterns)))
return ", ".join(notes)
async def cmd_upcoming(config: Config, args: argparse.Namespace) -> int:
now = datetime.now(UTC)
runner = await connect.read_calendars(config, now)
tz = connect.floating_tz(config)
horizon = now + timedelta(hours=args.hours)
shown = [e for e in runner.events if e.end > now and e.start < horizon]
for event in sorted(shown, key=lambda e: e.start):
start = event.start.astimezone(tz)
end = event.end.astimezone(tz)
print(
f"{start:%a %d %b %H:%M}-{end:%H:%M} [{event.calendar.source}/"
f"{event.calendar.name}] {event.title} ({upcoming_notes(config, event)})"
)
screen = schedule.choose(now, runner.events, runner.settings_for)
print(f"\nNow on the bar: {screen.kind + ': ' + screen.event.title if screen else 'nothing'}")
return 0
def setup_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if not verbose:
logging.getLogger("busylib").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpx2").setLevel(logging.WARNING)
# Its bounds check ignores anchors, so a bottom-anchored line at y=16 -
# busylib's own calibrated two-line layout - is reported as off-screen.
logging.getLogger("busylib.client.display").setLevel(logging.ERROR)
# "Device API minor version is behind Busy Lib": busylib's own nag, on every
# bar that runs released firmware; `check` prints both versions instead.
logging.getLogger("busylib.client.firmware").setLevel(logging.ERROR)
# It logs every refused call; this service reports those itself, with what to do.
logging.getLogger("busylib.client.base").setLevel(logging.CRITICAL)
FIRST_STEPS = """\
busybar-calendar shows your CalDAV and ICS calendars on a BUSY Bar: a
reminder before each event, then a red BUSY while it runs.
No config yet. To set it up:
busybar-calendar init a few questions: how to reach the bar, which calendar
busybar-calendar check proves it works, or says what to fix
busybar-calendar install runs it in the background from now on
busybar-calendar --help lists every command."""
def _status(args: argparse.Namespace, as_json: bool = False) -> int | None:
"""
`status`, which needs no config for its service part, and takes a broken
one in its stride: a running service keeps the last config that loaded,
so it is reported beside the problems. None when there is no config yet.
"""
try:
path = config_module.find(args.config)
except ConfigError:
path = None
if path is None or not path.is_file():
if as_json:
return asyncio.run(diagnose.status(None, as_json=True))
return None
try:
config, problems = config_module.load(path), []
except EXPECTED as error:
config, problems = None, describe(error)
return asyncio.run(diagnose.status(config, as_json, path=path, problems=problems))
def _where_you_stand(args: argparse.Namespace) -> int:
"""`busybar-calendar` alone: first steps before a config exists, the status after."""
code = _status(args)
if code is None:
print(FIRST_STEPS)
return 0
print("\nbusybar-calendar --help lists every command.")
return code
# The failures a user can meet and fix: reported in a line or two, never a traceback.
EXPECTED = (
ValidationError,
ConfigError,
CalDAVError,
sources.FatalSourceError,
httpx.HTTPError,
subprocess.CalledProcessError,
OSError,
ValueError,
)
def describe(error: BaseException) -> list[str]:
"""An expected failure in the user's terms: what went wrong, where."""
if isinstance(error, ValidationError): # the config: one line per problem
return [
f"{'.'.join(str(part) for part in problem['loc']) or 'config'}: "
f"{problem['msg'].removeprefix('Value error, ')}"
for problem in error.errors()
]
if isinstance(error, httpx.RequestError):
return [f"cannot reach {error.request.url}: {error}"]
if isinstance(error, subprocess.CalledProcessError): # launchctl, systemctl
return [f"{shlex.join(error.cmd)} failed (exit {error.returncode})"]
return [str(error)]
def _update_first() -> None:
"""`run --auto-update`, before the config is read - a newer version may be what reads
it - and once: not again in the process an update restarts into."""
if not os.environ.get(update.UPDATED_ENV) and update.update():
update.restart_into_update()
def _restart_after(code: int) -> int:
"""After the config changed: a running background service reads it only when it starts."""
if code == 0 and service.restart():
print("Restarted the background service, so it reads the new config.")
return code
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="busybar-calendar", description=__doc__)
parser.add_argument(
"-c",
"--config",
help="TOML configuration (default: $BUSYBAR_CALENDAR_CONFIG, ./config.toml, "
"then ~/.config/busybar-calendar/config.toml)",
)
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
# No command: where you stand, and what to do next (see _where_you_stand).
commands = parser.add_subparsers(dest="command")
run = commands.add_parser("run", help="keep the bar in step with the calendars")
run.add_argument(
"--auto-update",
action="store_true",
help="first update a uv tool installed from git to its latest version",
)
init = commands.add_parser("init", help="answer a few questions to write the config")
init.add_argument("--force", action="store_true", help="replace an existing config")
commands.add_parser("add", help="add another calendar to the config, a few questions")
check = commands.add_parser("check", help="check the bar, its token and every calendar")
check.add_argument("--json", action="store_true", help="the findings as JSON")
status = commands.add_parser(
"status", help="is it running, and what is on the bar now and next"
)
status.add_argument("--json", action="store_true", help="the same, as JSON")
install = commands.add_parser(
"install", help="run in the background from now on (launchd / systemd)"
)
install.add_argument("--dry-run", action="store_true", help="show what it would do")
install.add_argument("--force", action="store_true", help="install even if check fails")
install.add_argument(
"--auto-update",
action="store_true",
help="update to the latest version at every start and restart (uv tool from git)",
)
commands.add_parser("restart", help="restart the background service, e.g. after a new password")
uninstall = commands.add_parser("uninstall", help="stop and remove the background service")
uninstall.add_argument("--dry-run", action="store_true", help="show what it would do")
commands.add_parser("calendars", help="list the calendars each CalDAV source has")
upcoming = commands.add_parser("upcoming", help="list the events the bar will show")
upcoming.add_argument("--hours", type=int, default=24)
demo = commands.add_parser("demo", help="draw a sample event on the bar")
demo.add_argument("--title", default="Weekly sync with the team")
demo.add_argument("--minutes", type=int, default=5, help="reminder: minutes to the start")
demo.add_argument("--status", action="store_true", help="draw the in-event status instead")
demo.add_argument("--show", help="status to draw: name, meeting, on_call, ...")
demo.add_argument("--font", choices=["tiny", "small", "global"])
demo.add_argument("--sound", action="store_true")
demo.add_argument("--seconds", type=int, default=20, help="how long to leave it up")
replay = commands.add_parser(
"replay", help="play a day's events on the bar one after another, in short slots"
)
replay.add_argument("--day", default="tomorrow", help="today, tomorrow or YYYY-MM-DD")
replay.add_argument("--seconds", type=int, default=60, help="slot per event")
replay.add_argument("--quiet", action="store_true", help="no sounds")
args = parser.parse_args(argv)
setup_logging(args.verbose)
handlers = {
"run": cmd_run,
"check": cmd_check,
"install": cmd_install,
"calendars": cmd_calendars,
"upcoming": cmd_upcoming,
"demo": cmd_demo,
"replay": cmd_replay,
}
try:
if args.command == "uninstall": # needs no config: there may be none left
return cmd_uninstall(args)
if args.command == "restart":
return cmd_restart(args)
if args.command is None:
return _where_you_stand(args)
if args.command == "status":
code = _status(args, args.json)
return asyncio.run(diagnose.status(None)) if code is None else code
if args.command == "init": # writes the config
path = Path(
args.config
or os.environ.get(config_module.CONFIG_ENV)
or config_module.search_paths()[-1]
)
return _restart_after(wizard.run(path, overwrite=args.force))
if args.command == "add": # appends to the config
return _restart_after(wizard.add(config_module.find(args.config)))
if args.command == "run" and args.auto_update:
_update_first()
args.config_path = config_module.find(args.config)
config = config_module.load(args.config_path)
return asyncio.run(handlers[args.command](config, args))
except EXPECTED as error:
for line in describe(error):
print(f"busybar-calendar: {line}", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 130src/busybar_calendar/config.py
"""
Configuration, read from a TOML file.
The reminder and status settings are the ones the Google Calendar
integration offers per calendar, so a calendar here behaves the way a Google
calendar does on the same bar. They layer: `[defaults]` applies everywhere, a
source can override it, and a single calendar inside a source can override
that again - which is also the account-level default that BUSY Cloud does not
have yet (busy-app/busybar-firmware#1083).
"""
from __future__ import annotations
import os
import subprocess
import tomllib
from collections.abc import Iterable
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
ValidationInfo,
field_validator,
model_validator,
)
# The 72x16 statuses the firmware ships, under shared/animations/<name>_72x16.anim.
STOCK_STATUSES = (
"back_soon",
"booked",
"chill_time",
"coding",
"dnd",
"flow",
"keep_out",
"low_social_battery",
"lunch",
"meeting",
"on_air",
"on_call",
)
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid")
class ReminderSettings(_Strict):
"""A countdown shown before the event starts."""
enabled: bool = Field(default=True, description="Show a reminder before events.")
minutes_before: int = Field(
default=5, ge=1, le=24 * 60, description="How long before the start it appears."
)
sound: bool = Field(default=True, description="Play the reminder sound as it appears.")
show_name: bool = Field(
default=True, description='Show the event\'s title; off, it says "Next event".'
)
class StatusSettings(_Strict):
"""What the bar shows while the event is on."""
enabled: bool = Field(default=True, description="Show the event while it is on.")
show: str = Field(
default="name",
description="`name`: the red calendar, the title, a countdown and BUSY. Or one of "
"the bar's own statuses: " + ", ".join(f"`{s}`" for s in STOCK_STATUSES) + ".",
)
sound: bool = Field(default=True, description="Play the event sound as it starts.")
tentative: bool = Field(
default=True,
description="Events you marked tentative show as busy too. Off, they keep their "
"reminder only.",
)
@field_validator("show")
@classmethod
def _known_status(cls, value: str) -> str:
if value != "name" and value not in STOCK_STATUSES:
raise ValueError(f"show must be 'name' or one of {', '.join(STOCK_STATUSES)}")
return value
class Overrides(_Strict):
"""Partial reminder/status settings; unset fields fall through to the level above."""
reminder: dict[str, Any] = Field(default_factory=dict)
status: dict[str, Any] = Field(default_factory=dict)
@field_validator("reminder", "status")
@classmethod
def _valid(cls, value: dict[str, Any], info: ValidationInfo) -> dict[str, Any]:
"""Check the values here, where an error can say which section it is in."""
model = ReminderSettings if info.field_name == "reminder" else StatusSettings
try:
model(**value)
except ValidationError as error:
problems = (
f"{'.'.join(map(str, p['loc']))}: {p['msg'].removeprefix('Value error, ')}"
for p in error.errors()
)
raise ValueError("; ".join(problems)) from None
return value
def _merged(layers: Iterable[Overrides]) -> tuple[ReminderSettings, StatusSettings]:
reminder: dict[str, Any] = {}
status: dict[str, Any] = {}
for layer in layers:
reminder |= layer.reminder
status |= layer.status
return ReminderSettings(**reminder), StatusSettings(**status)
class TitleRule(Overrides):
"""Reminder/status overrides for the events whose title matches, from any calendar."""
match: list[str] = Field(
min_length=1,
description="The titles it applies to: one or a list; `*` and `?` are wildcards, "
"and case does not matter.",
)
@field_validator("match", mode="before")
@classmethod
def _one_or_many(cls, value: Any) -> Any:
return [value] if isinstance(value, str) else value
def matching(self, title: str) -> str | None:
"""The first of `match` that names this title, if one does."""
title = title.casefold()
return next((p for p in self.match if fnmatchcase(title, p.casefold())), None)
class Secret(_Strict):
"""
A credential given inline, by environment variable, or by a command.
The command form is argv, run without a shell - for example the macOS
keychain: ["security", "find-generic-password", "-s", "busybar", "-w"].
"""
value: str | None = None
env: str | None = None
command: list[str] | None = None
def resolve(self) -> str | None:
if self.value is not None:
return self.value
if self.env is not None:
found = os.environ.get(self.env)
if found is None:
raise ConfigError(f"environment variable {self.env} is not set")
return found
if self.command:
result = subprocess.run(self.command, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise ConfigError(f"{self.command[0]} exited with {result.returncode}")
return result.stdout.strip()
return None
def _secret(data: dict[str, Any], key: str) -> dict[str, Any]:
"""Fold `key`, `key_env` and `key_command` into one Secret."""
secret = {
"value": data.pop(key, None),
"env": data.pop(f"{key}_env", None),
"command": data.pop(f"{key}_command", None),
}
given = [name for name, value in secret.items() if value is not None]
if len(given) > 1:
raise ValueError(f"give only one of {key}, {key}_env, {key}_command")
data[key] = secret
return data
class BarConfig(_Strict):
address: str | None = Field(
default=None,
description="The bar on your network (`ip[:port]`; `10.0.4.20` over USB). Leave it "
"out to reach the bar through BUSY Cloud.",
)
token: Secret = Field(
default_factory=Secret,
description="With an address, the bar's HTTP API password (32 characters at most); "
'without, a BUSY Cloud token with the "BUSY Bar" access scope.',
)
priority: int = Field(
default=91,
ge=1,
le=100,
description="Draw priority: 91 is above a running BUSY session (90), like the bar's "
"own calendar events.",
)
application_name: str = Field(
default="busybar-calendar",
max_length=32,
description="The name drawings are made under; also the app's id in the community gallery.",
)
@model_validator(mode="before")
@classmethod
def _fold_token(cls, data: Any) -> Any:
return _secret(dict(data), "token") if isinstance(data, dict) else data
class DisplayConfig(_Strict):
title_font: Literal["tiny", "small", "global"] = Field(
default="small",
description="`small` fits the title and countdown on two lines; `global` draws "
"Cyrillic, CJK and accents, on one line beside the countdown.",
)
transliterate: bool = Field(
default=True,
description="Fold titles to ASCII for the bitmap fonts (`Планування` -> "
"`Planuvannya`). Not used with `global`.",
)
reminder_sound: str = Field(
default="shared/sounds/calendar_reminder_ends.snd",
description="The stock sound a reminder plays.",
)
event_sound: str = Field(
default="shared/sounds/calendar_event_starts.snd",
description="The stock sound an event's start plays.",
)
reminder_icon: Literal["animation", "red_calendar"] = Field(
default="red_calendar",
description="The icon before an event: the red calendar, or the bar's own animation.",
)
event_icon: Literal["animation", "red_calendar"] = Field(
default="red_calendar",
description="The icon during an event: the red calendar, or the bar's own animation.",
)
busy_label: str = Field(
default="BUSY",
max_length=8,
description='The word beside the countdown during an event; `""` for none. Not drawn '
"with the `global` font.",
)
busy_color: str = Field(
default="#FF0000FF", description="Its colour, `#RRGGBB` or `#RRGGBBAA`."
)
tentative_yellow: bool = Field(
default=True,
description="Draw events you marked tentative in yellow - the calendar icon and the "
"label - so they differ from accepted ones. Off, they look the same. The icon is "
"yellow only as the `red_calendar`; the bar's own animation has no yellow version.",
)
@field_validator("busy_label")
@classmethod
def _ascii_label(cls, value: str) -> str:
if not all(" " <= c <= "~" for c in value):
raise ValueError("busy_label must be printable ASCII")
return value
@field_validator("busy_color")
@classmethod
def _rgba(cls, value: str) -> str:
digits = value.removeprefix("#")
if len(digits) not in (6, 8) or any(c not in "0123456789abcdefABCDEF" for c in digits):
raise ValueError("busy_color must be #RRGGBB or #RRGGBBAA")
return f"#{digits.upper()}" + ("FF" if len(digits) == 6 else "")
class SyncConfig(_Strict):
poll_minutes: int = Field(default=5, ge=1, description="How often calendars are read.")
lookahead_hours: int = Field(
default=24, ge=1, le=24 * 14, description="How far ahead events are read."
)
skip_free: bool = Field(
default=True, description="Leave out events marked free (`TRANSP:TRANSPARENT`)."
)
timezone: str | None = Field(
default=None,
description="For event times that carry no timezone, e.g. `Europe/Kyiv`. Default: "
"this machine's.",
)
@field_validator("timezone")
@classmethod
def _known_timezone(cls, value: str | None) -> str | None:
if value is not None:
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as error:
raise ValueError(
f"{value!r} is not a timezone name; use one like 'Europe/Kyiv'"
) from error
return value
class SourceConfig(Overrides):
name: str = Field(description="What logs and overrides call this source; unique.")
type: Literal["caldav", "ics"] = Field(
description="`caldav` for an account, `ics` for a published link (one calendar)."
)
url: str = Field(
description="CalDAV: the server, or one calendar's URL. ICS: the link; `webcal://` works."
)
username: str | None = Field(default=None, description="Usually the email address.")
password: Secret = Field(
default_factory=Secret,
description="iCloud and Fastmail need an app password.",
)
auth: Literal["basic", "digest"] = Field(
default="basic", description="HTTP authentication; a few servers want `digest`."
)
emails: list[str] = Field(
default_factory=list,
description="Your attendee address(es), to read your response to invitations. "
"Default: the username, when it is an email address.",
)
include: list[str] = Field(
default_factory=list,
description="CalDAV: only these calendars, by name, id or URL; `*` and `?` are "
"wildcards. Default: all. The one listed last wins an overlap.",
)
exclude: list[str] = Field(
default_factory=list, description="CalDAV: leave these calendars out, after include."
)
calendars: dict[str, Overrides] = Field(
default_factory=dict,
description="Per-calendar reminder and status overrides, keyed by name or id.",
)
@model_validator(mode="before")
@classmethod
def _fold_password(cls, data: Any) -> Any:
return _secret(dict(data), "password") if isinstance(data, dict) else data
@model_validator(mode="after")
def _defaults(self) -> SourceConfig:
if self.url.startswith("webcal://"):
self.url = "https://" + self.url.removeprefix("webcal://")
if not self.emails and self.username and "@" in self.username:
self.emails = [self.username]
if self.type == "ics" and (self.include or self.exclude or self.calendars):
# An ICS feed is one calendar, named after its source.
raise ValueError(
f"source {self.name!r}: include/exclude/calendars apply to caldav only"
)
return self
class Config(_Strict):
bar: BarConfig = Field(default_factory=BarConfig)
sync: SyncConfig = Field(default_factory=SyncConfig)
display: DisplayConfig = Field(default_factory=DisplayConfig)
defaults: Overrides = Field(default_factory=Overrides)
# Empty only for the gallery app's --demo and --test, which need no calendar.
sources: list[SourceConfig] = Field(default_factory=list)
titles: list[TitleRule] = Field(default_factory=list)
@model_validator(mode="after")
def _unique_names(self) -> Config:
names = [source.name for source in self.sources]
if len(names) != len(set(names)):
raise ValueError("source names must be unique")
return self
def settings_for(
self, source: str, calendar: str, calendar_id: str = "", title: str = ""
) -> tuple[ReminderSettings, StatusSettings]:
"""
The effective settings of one event: defaults, then the source, then the
calendar's overrides by name, then by id, then every `[[titles]]` rule
that matches the title, in order.
"""
layers: list[Overrides] = [self.defaults]
src = next((s for s in self.sources if s.name == source), None)
if src is not None: # an event of no configured source (a demo) gets the defaults
layers.append(src)
layers += [src.calendars[k] for k in (calendar, calendar_id) if k in src.calendars]
layers += [rule for rule in self.titles if rule.matching(title) is not None]
return _merged(layers)
def title_patterns(self, title: str) -> list[str]:
"""The `[[titles]]` patterns that name this title, one per rule that applies."""
return [p for rule in self.titles if (p := rule.matching(title)) is not None]
class ConfigError(Exception):
pass
CONFIG_ENV = "BUSYBAR_CALENDAR_CONFIG"
def search_paths() -> list[Path]:
"""Where a config is looked for when none is named: here, then the user's config dir."""
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
return [Path("config.toml"), config_home / "busybar-calendar" / "config.toml"]
def find(explicit: str | Path | None = None) -> Path:
"""The config file: the one named, else $BUSYBAR_CALENDAR_CONFIG, else the first that exists."""
if explicit:
return Path(explicit)
if os.environ.get(CONFIG_ENV):
return Path(os.environ[CONFIG_ENV])
for path in search_paths():
if path.is_file():
return path
tried = ", ".join(str(path) for path in search_paths())
raise ConfigError(
f"no config yet (looked for {tried}): run `busybar-calendar init` to write one, "
f"or name one with -c or ${CONFIG_ENV}"
)
def load(path: str | Path) -> Config:
if not Path(path).is_file():
raise ConfigError(f"{path}: no such config; `busybar-calendar init` writes one")
with open(path, "rb") as handle:
data = tomllib.load(handle)
config = Config.model_validate(data)
if not config.sources:
raise ConfigError(f"{path}: no [[sources]]: add a CalDAV account or an ICS feed")
return configsrc/busybar_calendar/connect.py
"""The plumbing every command shares: the bar client, the calendar sources, signals."""
from __future__ import annotations
import asyncio
import contextlib
import signal
from datetime import datetime, tzinfo
from zoneinfo import ZoneInfo
from busylib.client import AsyncBusyBar
from . import sources
from .config import Config, ConfigError
from .runner import Runner
def floating_tz(config: Config) -> tzinfo:
if config.sync.timezone:
return ZoneInfo(config.sync.timezone)
local = datetime.now().astimezone().tzinfo
assert local is not None
return local
def make_sources(config: Config) -> list[sources.Source]:
tz = floating_tz(config)
return [
sources.build(source, index, tz, config.sync.skip_free)
for index, source in enumerate(config.sources)
]
def make_bar(config: Config) -> AsyncBusyBar:
token = config.bar.token.resolve()
if config.bar.address:
return AsyncBusyBar(addr=config.bar.address, token=token)
if not token:
raise ConfigError("give bar.address, or a bar-scope cloud token as bar.token")
return AsyncBusyBar(token=token)
async def close_all(items: list[sources.Source]) -> None:
for item in items:
await item.aclose()
async def read_calendars(config: Config, now: datetime) -> Runner:
"""The events and their settings as the service sees them at `now`, with no bar."""
items = make_sources(config)
runner = Runner(config, bar=None, sources=items) # type: ignore[arg-type]
try:
await runner.refresh(now)
finally:
await close_all(items)
return runner
def stop_on_signals() -> asyncio.Event:
"""An event that SIGINT or SIGTERM sets, so every mode can stop cleanly."""
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
return stopsrc/busybar_calendar/diagnose.py
"""`check` and `status`: is the setup right, is the service running, what is on the bar."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sys
from collections.abc import Callable, Iterator, Sequence
from datetime import UTC, datetime, timedelta, tzinfo
from pathlib import Path
from busylib.client import AsyncBusyBar
from busylib.exceptions import BusyBarAPIError
from . import __version__, connect, schedule, service, sources, update
from .config import Config, ConfigError
from .events import Event
LAN_TOKEN_LIMIT = 32 # the firmware refuses longer X-API-Token values
# report(ok, message, **fields): one finding of `check`, printed or collected as JSON.
Report = Callable[..., None]
async def _check_bar(config: Config, report: Report) -> None:
where = config.bar.address or "BUSY Cloud"
bar = connect.make_bar(config)
try:
try:
version = await bar.version()
firmware = await bar.status_firmware()
except BusyBarAPIError as error: # it answered: the token, not the network
if error.status_code not in (401, 403):
report(False, f"{where}: {error}", area="bar", where=where)
return
report(
False,
f"{where}: refused ({error.status_code}). {await _access_hint(bar, config, error)}",
area="bar",
where=where,
status=error.status_code,
)
return
except Exception as error:
report(False, f"{where}: unreachable ({error})", area="bar", where=where)
return
report(
True,
f"{where}: firmware {firmware.version}, API {version.api_semver}, token accepted",
area="bar",
where=where,
firmware=firmware.version,
api=version.api_semver,
)
finally:
await bar.aclose()
async def _access_hint(bar: AsyncBusyBar, config: Config, error: BusyBarAPIError) -> str:
"""Why an authenticated call was refused, in the words of the fix."""
if not config.bar.address:
return "The cloud token is wrong, revoked, or lacks the BUSY Bar access scope."
try:
access = await bar.access() # answered without a token
except Exception:
access = None
if access is not None and access.mode == "disabled":
return (
"The bar's HTTP API over Wi-Fi is disabled: enable it at http://10.0.4.20 "
"(USB) -> Network -> HTTP API, or use a BUSY Cloud token without an address."
)
token = config.bar.token.resolve()
if not token:
return "The bar wants its HTTP API password: set bar.token / token_env / token_command."
if len(token) > LAN_TOKEN_LIMIT:
return (
f"The token is {len(token)} characters; the bar takes at most "
f"{LAN_TOKEN_LIMIT}. Is it a cloud token? Then leave bar.address out."
)
return "The bar did not accept the password."
async def _check_source(item: sources.Source, report: Report, tz: tzinfo) -> None:
now = datetime.now(UTC)
where = {"area": "calendar", "source": item.name}
try:
if isinstance(item, sources.CalDAVSource):
found = await item.calendars()
used, unmatched = sources.select(found, item.config.include, item.config.exclude)
names = ", ".join(c.name for c in used) or "none"
report(
bool(used),
f"{item.name}: signed in; {len(used)} of {len(found)} calendars used: {names}",
**where,
calendars=[c.name for c in used],
available=len(found),
)
available = ", ".join(c.name for c in found[:MAX_NAMES])
more = f" and {len(found) - MAX_NAMES} more" if len(found) > MAX_NAMES else ""
for pattern in unmatched:
report(
False,
f"{item.name}: no calendar matches {pattern!r}; there are: {available}{more}",
**where,
unmatched=pattern,
)
if not used:
return # no calendar is read, so there are no events to report on
with _quiet(sources.logger): # its warnings repeat what is reported here
events = await item.fetch(now - timedelta(hours=24), now + timedelta(days=7))
except Exception as error:
report(False, f"{item.name}: {error}", **where)
return
ahead = sorted((e for e in events if e.end > now), key=lambda e: e.start)
if not ahead:
report(True, f"{item.name}: no events in the next 7 days", **where, events=0)
return
first = ahead[0]
report(
True,
f"{item.name}: {len(ahead)} events in the next 7 days; next "
f"{first.start.astimezone(tz):%a %d %b %H:%M} {first.title}",
**where,
events=len(ahead),
next=_event_json(first, tz),
)
MAX_NAMES = 8
@contextlib.contextmanager
def _quiet(log: logging.Logger) -> Iterator[None]:
level = log.level
log.setLevel(logging.ERROR)
try:
yield
finally:
log.setLevel(level)
def _iso(moment: datetime, tz: tzinfo) -> str:
return moment.astimezone(tz).isoformat(timespec="minutes")
def _event_json(event: Event, tz: tzinfo) -> dict:
return {
"title": event.title,
"start": _iso(event.start, tz),
"end": _iso(event.end, tz),
"calendar": event.calendar.name,
"response": event.response,
}
async def check(config: Config, as_json: bool = False) -> int:
"""Check the setup end to end: the bar, its token, every calendar."""
findings: list[dict] = []
def report(good: bool, text: str, **fields: object) -> None:
findings.append({"ok": good, "message": text, **fields})
if not as_json:
print(f" {'ok ' if good else 'FAIL'} {text}")
if not as_json:
print(f"busybar-calendar {__version__}\nBar")
await _check_bar(config, report)
if not as_json:
print("Calendars")
items = connect.make_sources(config)
try:
for item in items:
await _check_source(item, report, connect.floating_tz(config))
finally:
await connect.close_all(items)
problems = sum(not finding["ok"] for finding in findings)
if as_json:
result = {"version": __version__, "ok": not problems, "findings": findings}
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print("\nAll good." if not problems else f"\n{problems} problem(s) above.")
return 0 if not problems else 1
def _when(moment: datetime, now: datetime, tz: tzinfo) -> str:
minutes = max(0, int((moment - now).total_seconds() // 60))
hours, minutes = divmod(minutes, 60)
delta = f"in {hours}h {minutes:02d}m" if hours else f"in {minutes}m"
return f"{moment.astimezone(tz):%a %d %b %H:%M} ({delta})"
def _screen_json(screen: schedule.Screen, tz: tzinfo, **moment: str) -> dict:
return {"kind": screen.kind, **_event_json(screen.event, tz), **moment}
BAR_TIMEOUT = 5.0 # seconds; a bar that takes longer is as good as not answering
async def bar_health(config: Config, timeout: float = BAR_TIMEOUT) -> dict:
"""Whether the bar answers at all: one light, read-only call, as `check` makes."""
where = config.bar.address or "BUSY Cloud"
try:
bar = connect.make_bar(config)
except ConfigError as error: # no address and no token: nothing to ask
return {"where": where, "answering": False, "detail": str(error)}
try:
await asyncio.wait_for(bar.version(), timeout)
return {"where": where, "answering": True, "detail": None}
except BusyBarAPIError as error:
if error.status_code in (401, 403): # it is there; the token is the problem
return {
"where": where,
"answering": True,
"detail": f"refused ({error.status_code}): `busybar-calendar check` says why",
}
return {"where": where, "answering": False, "detail": str(error)}
except Exception as error:
detail = (
"no answer" if isinstance(error, TimeoutError) else str(error) or type(error).__name__
)
return {"where": where, "answering": False, "detail": detail}
finally:
with contextlib.suppress(Exception):
await bar.aclose()
async def status_data(
config: Config | None, path: Path | None = None, problems: Sequence[str] = ()
) -> dict:
"""
What `status` reports, as data: the service, the config's problems if it
does not load, and the calendar's screen now and next.
"""
state = service.status()
data: dict = {
"version": __version__,
"service": {
"installed": state.installed,
"running": state.running,
"pid": state.pid,
"detail": state.detail or None,
"log": str(state.log) if state.log and state.log.exists() else None,
"recent_log": service.recent_log(state),
"version": service.running_version(state) if state.running else None,
"python": service.installed_python() if state.installed else None,
"auto_update": service.auto_updates() if state.installed else False,
},
"config": {"path": str(path) if path else None, "problems": list(problems)},
"bar": None,
"calendar": None,
}
if config is None:
return data
data["bar"] = await bar_health(config)
now = datetime.now(UTC)
runner = await connect.read_calendars(config, now)
tz = connect.floating_tz(config)
screen = schedule.choose(now, runner.events, runner.settings_for)
following = schedule.upcoming(now, runner.events, runner.settings_for)
data["calendar"] = {
"lookahead_hours": config.sync.lookahead_hours,
"now": _screen_json(screen, tz, until=_iso(screen.until, tz)) if screen else None,
"next": _screen_json(following[1], tz, at=_iso(following[0], tz)) if following else None,
}
return data
def _environment(python: str) -> str:
"""The environment an interpreter belongs to (its bin/ folder's parent), home as ~."""
folder = Path(python).parent.parent
with contextlib.suppress(ValueError):
return str(Path("~") / folder.relative_to(Path.home()))
return str(folder)
def upgrade_command(python: str) -> str | None:
"""`uv tool upgrade` for a uv tool installed from git, the install it can move forward.
A tool installed from a wheel file would reinstall that same file.
"""
return " ".join(update.upgrade_command()) if update.from_git(python) else None
def print_status(data: dict) -> None:
print(f"busybar-calendar {data['version']}")
svc = data["service"]
if svc["running"]:
pid = f", pid {svc['pid']}" if svc["pid"] else ""
running = svc.get("version")
updating = ", updates itself at each start" if svc.get("auto_update") else ""
print(f"Service running{f' {running}' if running else ''}{pid}{updating}")
if running and running != data["version"]:
python = svc.get("python")
if svc.get("auto_update"):
print(
f" that is {running}, not this {data['version']}: "
"`busybar-calendar restart` updates it"
)
elif python and python != sys.executable:
# Another environment: restarting it starts the same old version again.
print(
f" that is {running}, from {_environment(python)}, "
f"not this {data['version']}:"
)
command = upgrade_command(python)
how = f"`{command}`" if command else "upgrade that one"
print(f" {how}, then `busybar-calendar restart`")
else:
print(
f" that is {running}, but this is {data['version']}: "
"`busybar-calendar restart` runs this one"
)
elif svc["installed"]:
why = f" ({svc['detail']})" if svc["detail"] else ""
print(f"Service stopped{why}; the log says why")
else:
print("Service not installed: `busybar-calendar install` runs it in the background")
if svc["log"]:
print(f" log {svc['log']}")
for line in svc["recent_log"]:
print(f" {line}")
bar = data.get("bar")
if bar is not None:
if bar["answering"] and not bar["detail"]:
print(f"Bar {bar['where']}: answering")
elif bar["answering"]:
print(f"Bar {bar['where']}: {bar['detail']}")
else:
print(
f"Bar {bar['where']}: NOT answering ({bar['detail']}). Is it on and "
"online? If it stays stuck, unplug it and plug it back in."
)
problems = data["config"]["problems"]
if problems:
print(f"Config does not load: {data['config']['path']}")
if svc["running"]:
print(" the running service keeps the config it last loaded")
for line in problems:
print(f" {line}")
calendar = data["calendar"]
if calendar is None:
if not problems:
print("Calendar no config yet: `busybar-calendar init` writes one")
return
now = datetime.now(UTC)
if calendar["now"] is None:
print("Calendar nothing on the bar now")
else:
current = calendar["now"]
until = datetime.fromisoformat(current["until"])
print(
f"Calendar now {current['kind']}: {current['title']}, "
f"until {_when(until, now, until.tzinfo)}"
)
if calendar["next"] is None:
print(f" nothing more in the next {calendar['lookahead_hours']} hours")
else:
nxt = calendar["next"]
at = datetime.fromisoformat(nxt["at"])
print(f" next {nxt['kind']}: {nxt['title']}, at {_when(at, now, at.tzinfo)}")
async def status(
config: Config | None,
as_json: bool = False,
*,
path: Path | None = None,
problems: Sequence[str] = (),
) -> int:
"""Is it running, what did it last do, and what is on the bar now and next.
1 when the config does not load (`problems`), so a script notices.
"""
data = await status_data(config, path, problems)
if as_json:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print_status(data)
return 1 if problems else 0src/busybar_calendar/events.py
"""
Calendar events, reduced to what the bar needs, from iCalendar data.
CalDAV and ICS feeds both end up here: CalDAV returns one VCALENDAR per
event resource, an ICS feed returns one for the whole calendar, and either
way recurring events arrive as a master with RRULE/EXDATE and overrides.
Expansion happens here rather than on the server, because servers differ in
how well they implement CalDAV's `expand`.
The filtering follows the Google Calendar integration: events of 24 hours or
more are not shown, and neither are cancelled ones. Free events and
invitations you declined or delegated are skipped too, since none of them
makes you busy. Your answer to the rest (accepted, tentative, not yet) is
kept, because it decides which of two overlapping events the bar shows.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta, tzinfo
import icalendar
import recurring_ical_events
logger = logging.getLogger(__name__)
ALL_DAY = timedelta(hours=24)
UNTITLED = "Busy"
@dataclass(frozen=True)
class CalendarRef:
source: str
name: str
# Position in the configuration: a calendar added later wins an overlap.
order: int
# The CalDAV calendar's id (its URL's last segment); empty for an ICS feed.
id: str = ""
@dataclass(frozen=True)
class Event:
uid: str
title: str
start: datetime
end: datetime
calendar: CalendarRef
# Your answer to the invitation: "accepted", "tentative" or "needs-action".
# Events of your own, with no attendees, count as accepted.
response: str = "accepted"
@property
def key(self) -> str:
"""One occurrence of one event; a recurring event has one per start."""
return f"{self.calendar.source}/{self.calendar.name}/{self.uid}@{self.start.isoformat()}"
def _aware(value: datetime, floating_tz: tzinfo) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=floating_tz)
return value.astimezone(UTC)
# Which of two overlapping events wins first depends on how sure you are to
# be there. Declined and delegated invitations are not shown at all.
RESPONSE_RANK = {"accepted": 2, "tentative": 1, "needs-action": 0}
_PARTSTAT = {"ACCEPTED": "accepted", "TENTATIVE": "tentative"}
_NOT_ATTENDING = {"DECLINED", "DELEGATED"}
def _address(value: object) -> str:
return str(value).lower().removeprefix("mailto:")
def _marked(component: icalendar.Event) -> str:
"""How sure the event itself says it is: Outlook's busy status, or STATUS."""
busy = str(component.get("X-MICROSOFT-CDO-BUSYSTATUS", "")).upper()
status = str(component.get("STATUS", "")).upper()
return "tentative" if "TENTATIVE" in (busy, status) else "accepted"
def _response(component: icalendar.Event, emails: Iterable[str]) -> str | None:
"""
Your answer to this event, or None when you are not going.
Your own attendee entry decides, even on an event you organise. Without
one to read - an event of your own, a published feed (which leaves the
attendees out), or no address of yours to look for - the event's own
marking does: tentative when it says so, accepted otherwise.
"""
mine = {email.lower() for email in emails}
attendees = component.get("ATTENDEE", [])
if not isinstance(attendees, list):
attendees = [attendees]
if not attendees or not mine:
return _marked(component)
for attendee in attendees:
if _address(attendee) in mine:
# RFC 5545: an attendee without PARTSTAT has not answered.
partstat = str(attendee.params.get("PARTSTAT", "NEEDS-ACTION")).upper()
if partstat in _NOT_ATTENDING:
return None
return _PARTSTAT.get(partstat, "needs-action")
if _address(component.get("ORGANIZER", "")) in mine:
return "accepted"
# Invited some other way, say through a group: no answer of yours on record.
return "needs-action"
def parse(
data: str | bytes | Iterable[str | bytes],
calendar: CalendarRef,
window_start: datetime,
window_end: datetime,
*,
floating_tz: tzinfo,
skip_free: bool = True,
emails: Iterable[str] = (),
) -> list[Event]:
"""Every occurrence that overlaps the window and is worth showing."""
documents = [data] if isinstance(data, str | bytes) else list(data)
emails = list(emails)
events: list[Event] = []
for document in documents:
try:
parsed = icalendar.Calendar.from_ical(document)
occurrences = recurring_ical_events.of(parsed).between(window_start, window_end)
except Exception as error: # one broken resource must not hide the rest
logger.warning(
"%s/%s: skipping unparsable calendar data: %s",
calendar.source,
calendar.name,
error,
)
continue
for component in occurrences:
if component.name != "VEVENT":
continue
event = _event(component, calendar, floating_tz, skip_free, emails)
if event is not None:
events.append(event)
events.sort(key=lambda e: (e.start, e.calendar.order))
return events
def _event(
component: icalendar.Event,
calendar: CalendarRef,
floating_tz: tzinfo,
skip_free: bool,
emails: list[str],
) -> Event | None:
start_value = component.get("DTSTART")
if start_value is None:
return None
start_raw = start_value.dt
if isinstance(start_raw, date) and not isinstance(start_raw, datetime):
return None # an all-day event
start = _aware(start_raw, floating_tz)
if component.get("DTEND") is not None:
end = _aware(component["DTEND"].dt, floating_tz)
elif component.get("DURATION") is not None:
end = start + component["DURATION"].dt
else:
end = start
if end - start >= ALL_DAY:
return None
if str(component.get("STATUS", "")).upper() == "CANCELLED":
return None
if skip_free and str(component.get("TRANSP", "")).upper() == "TRANSPARENT":
return None
response = _response(component, emails)
if response is None:
return None
title = " ".join(str(component.get("SUMMARY", "")).split()) or UNTITLED
return Event(
uid=str(component.get("UID", title)),
title=title,
start=start,
end=end,
calendar=calendar,
response=response,
)src/busybar_calendar/icons.py
"""
Icons drawn inline, as XPM2, so nothing has to be uploaded to the bar first.
RED_CALENDAR is the "calendar" emoji (U+1F4C5) of Google's Noto Emoji
(https://github.com/googlefonts/noto-emoji, Copyright 2013 Google LLC),
scaled to 16x16 and redrawn pixel by pixel for the LED panel: a solid red
header with the binder holes, a white page and a crisp "17", without the
month lettering that blurs at this size. YELLOW_CALENDAR is the same
drawing with a yellow header, for events you marked tentative. As Modified
Versions of Noto Emoji they are under the SIL Open Font License 1.1, unlike
the rest of this project: see LICENSES/noto-emoji-OFL.txt.
"""
from __future__ import annotations
# One character per pixel: . clear, R the header colour, P its pale divider,
# W page, G page edge, K the digits (black is an unlit LED).
_CALENDAR_PIXELS = (
".RRRRRRRRRRRRRR.",
"RR.RRRRRRRRRR.RR",
"RRRRRRRRRRRRRRRR",
"RRRRRRRRRRRRRRRR",
"RRRRRRRRRRRRRRRR",
"RRRRRRRRRRRRRRRR",
"GPPPPPPPPPPPPPPG",
"GWWWWWWWWWWWWWWG",
"GWWWWKWWKKKKWWWG",
"GWWWKKWWWWWKWWWG",
"GWWWWKWWWWKWWWWG",
"GWWWWKWWWWKWWWWG",
"GWWWWKWWWKWWWWWG",
"GWWWWKWWWKWWWWWG",
"GWWWWWWWWWWWWWWG",
".GGGGGGGGGGGGGG.",
)
def _colours(header: str, divider: str) -> dict[str, str]:
return {
".": "none",
"R": header,
"P": divider,
"W": "#F5F5F5",
"G": "#B0B0B0",
"K": "#000000",
}
def xpm(pixels: tuple[str, ...], colours: dict[str, str]) -> str:
"""An XPM2 image, in the form the bar's xpmbitmap element takes."""
width, height = len(pixels[0]), len(pixels)
assert all(len(row) == width for row in pixels)
lines = ["! XPM2", f"{width} {height} {len(colours)} 1"]
lines += [f"{char} c {value}" for char, value in colours.items()]
lines += pixels
return "\n".join(lines) + "\n"
RED_CALENDAR = xpm(_CALENDAR_PIXELS, _colours("#F44336", "#F8C6C2"))
YELLOW_CALENDAR = xpm(_CALENDAR_PIXELS, _colours("#FFC800", "#FFEBA0"))src/busybar_calendar/playback.py
"""Showing events on the bar outside the schedule: replay a day, the demo, test frames."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import dataclasses
import json
import logging
from datetime import UTC, date, datetime, time, timedelta, tzinfo
from . import connect, render, schedule
from .config import Config
from .events import CalendarRef, Event
from .runner import Runner
logger = logging.getLogger(__name__)
def _day(text: str, tz: tzinfo) -> date:
today = datetime.now(tz).date()
named = {"today": today, "tomorrow": today + timedelta(days=1)}
return named.get(text) or date.fromisoformat(text)
async def _wait(stop: asyncio.Event, moment: datetime) -> bool:
"""Sleep until `moment`; True if `stop` was set first."""
delay = max(0.0, (moment - datetime.now(UTC)).total_seconds())
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), delay)
return stop.is_set()
async def _show(runner: Runner, screen: schedule.Screen | None, *, chime: bool) -> None:
"""Draw (or, with no screen, clear) and carry on if the bar refuses: a 409 means
something above our priority holds the screen - a menu, a button press."""
try:
if screen is None:
await runner.clear()
else:
await runner.show(screen, chime=chime)
except Exception as error:
logger.warning("bar refused the update (%s); carrying on", error)
async def play(
runner: Runner,
events: list[Event],
seconds: int,
stop: asyncio.Event,
*,
chime: bool,
tz: tzinfo | None = None,
) -> None:
"""
Show events one after another, each in a slot of `seconds`.
A third of the slot is the reminder, counting down to a start moved to
its end; the rest is the status. Titles, settings and drawings are the
events' own; only the times are not.
"""
reminder_part = timedelta(seconds=max(5, seconds // 3))
status_part = timedelta(seconds=seconds) - reminder_part
for number, event in enumerate(events, 1):
reminder, status = runner.settings_for(event)
start = datetime.now(UTC).replace(microsecond=0) + reminder_part
moved = dataclasses.replace(event, start=start, end=start + status_part)
if tz is not None:
print(
f"{number:>2}/{len(events)} {event.start.astimezone(tz):%H:%M}-"
f"{event.end.astimezone(tz):%H:%M} {event.title} [{event.response}]",
flush=True,
)
if reminder.enabled:
await _show(runner, schedule.Screen("reminder", moved, reminder, status), chime=chime)
if await _wait(stop, moved.start):
return
shown = schedule.shows_status(moved, status)
await _show(
runner,
schedule.Screen("status", moved, reminder, status) if shown else None,
chime=chime,
)
if await _wait(stop, moved.end):
return
async def replay(config: Config, day: str, seconds: int, quiet: bool) -> int:
"""Play a day's real events on the bar, one after another, in short slots."""
tz = connect.floating_tz(config)
day = _day(day, tz)
day_start = datetime.combine(day, time.min, tzinfo=tz)
day_end = day_start + timedelta(days=1)
items = connect.make_sources(config)
try:
found = await asyncio.gather(*(item.fetch(day_start, day_end) for item in items))
finally:
await connect.close_all(items)
events = sorted(
(e for events in found for e in events if day_start <= e.start < day_end),
key=lambda e: (e.start, e.calendar.order),
)
if not events:
print(f"No events on {day:%a %d %b}.")
return 0
print(f"Replaying {len(events)} events of {day:%a %d %b}, {seconds}s each.")
bar = connect.make_bar(config)
runner = Runner(config, bar, [])
stop = connect.stop_on_signals()
try:
await play(runner, events, seconds, stop, chime=not quiet, tz=tz)
finally:
with contextlib.suppress(Exception):
await runner.clear()
await bar.aclose()
return 0
DEMO_EVENTS = (
("Design review", "accepted"),
("1:1 with Alex", "accepted"),
("Quarterly planning with the design team", "tentative"),
)
def _demo_events() -> list[Event]:
ref = CalendarRef("demo", "demo", 0)
now = datetime.now(UTC)
return [
Event(f"demo-{i}", title, now, now + timedelta(minutes=30), ref, response)
for i, (title, response) in enumerate(DEMO_EVENTS)
]
async def run_demo(config: Config, seconds: int = 20) -> int:
"""Loop a made-up schedule on the bar until a signal: reminders, then BUSY."""
bar = connect.make_bar(config)
runner = Runner(config, bar, [])
stop = connect.stop_on_signals()
try:
while not stop.is_set():
await play(runner, _demo_events(), seconds, stop, chime=False)
finally:
with contextlib.suppress(Exception):
await runner.clear()
await bar.aclose()
return 0
async def draw_test_frame(config: Config) -> int:
"""Draw one made-up event in progress - the red calendar and BUSY - and exit."""
event = dataclasses.replace(_demo_events()[0], start=datetime.now(UTC) - timedelta(minutes=2))
reminder, status = config.settings_for("demo", "demo")
screen = schedule.Screen("status", event, reminder, status)
bar = connect.make_bar(config)
try:
drawing = render.drawing(
screen, config.display, config.bar.application_name, config.bar.priority
)
await render.send(bar, drawing)
finally:
await bar.aclose()
return 0
async def demo(config: Config, args: argparse.Namespace) -> int:
"""Draw a made-up event, to see the layout on the bar."""
now = datetime.now(UTC)
start = now + timedelta(minutes=args.minutes)
if args.status:
start = now - timedelta(minutes=1)
event = Event(
"demo", args.title, start, start + timedelta(minutes=30), CalendarRef("demo", "demo", 0)
)
first = config.sources[0].name
reminder, status = config.settings_for(first, first, title=args.title)
if args.show:
status = status.model_copy(update={"show": args.show})
screen = schedule.Screen("status" if args.status else "reminder", event, reminder, status)
if args.font:
config.display.title_font = args.font
drawing = render.drawing(
screen, config.display, config.bar.application_name, config.bar.priority
)
print(json.dumps(drawing.payload(), indent=2, ensure_ascii=False))
bar = connect.make_bar(config)
try:
with contextlib.suppress(Exception): # 400 when nothing of ours is up
await bar.display_clear(application_name=config.bar.application_name)
await render.send(bar, drawing)
if args.sound and (sound := render.sound(screen, config.display)):
await bar.audio_play(stock_path=sound, application_name=config.bar.application_name)
await asyncio.sleep(args.seconds)
with contextlib.suppress(Exception):
await bar.display_clear(application_name=config.bar.application_name)
finally:
await bar.aclose()
return 0src/busybar_calendar/reference.py
"""
The configuration reference, generated from the models in config.py.
python -m busybar_calendar.reference > docs/configuration.md
A test compares docs/configuration.md with this output, so the reference
cannot fall behind the code.
"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from .config import (
BarConfig,
DisplayConfig,
ReminderSettings,
Secret,
SourceConfig,
StatusSettings,
SyncConfig,
TitleRule,
)
INTRO = """\
# Configuration reference
The config is a TOML file. `busybar-calendar init` writes one; it is found
in `~/.config/busybar-calendar/config.toml`, `./config.toml`, or wherever
`$BUSYBAR_CALENDAR_CONFIG` or `-c` points. Only `[[sources]]` is required;
everything else has the default shown.
Secrets - `token` and `password` - take one of three forms. Prefer the last
two, which keep the secret out of the file:
```toml
password = "..." # inline
password_env = "CALDAV_PASSWORD" # an environment variable
password_command = ["security", "find-generic-password", "-s", "my-calendar", "-w"]
```
The command is run without a shell, and its output is the secret.
*Generated from the code by `python -m busybar_calendar.reference`; edit the
descriptions in `config.py`, not this file.*
"""
TITLES = """\
## `[[titles]]`
Settings for the events whose title matches, from whichever calendar: a
rule's `reminder` and `status` go on top of the calendar's, and a later rule
on top of an earlier one. To keep some events off the bar, and show lunch as
the bar's own status:
```toml
[[titles]]
match = ["Focus time", "Commute*"]
reminder.enabled = false
status.enabled = false
[[titles]]
match = "Lunch*"
reminder.enabled = false
status.show = "lunch"
```
`busybar-calendar upcoming` shows what each event ends up with.
"""
def _default(field: FieldInfo) -> str:
if field.is_required():
return "**required**"
if field.default_factory is not None:
made = field.default_factory()
if isinstance(made, Secret):
return "none"
return "`[]`" if isinstance(made, list) else "`{}`"
value = field.default
if value is None:
return "none"
if isinstance(value, bool):
return f"`{str(value).lower()}`"
if isinstance(value, str):
return f'`"{value}"`'
return f"`{value}`"
def _key(name: str, field: FieldInfo) -> str:
if field.annotation is Secret or field.annotation == "Secret":
return f"`{name}` / `{name}_env` / `{name}_command`"
return f"`{name}`"
def table(model: type[BaseModel], skip: tuple[str, ...] = ()) -> str:
rows = ["| Key | Default | |", "| --- | --- | --- |"]
for name, field in model.model_fields.items():
if name in skip:
continue
text = (field.description or "").replace("|", "\\|")
rows.append(f"| {_key(name, field)} | {_default(field)} | {text} |")
return "\n".join(rows)
def markdown() -> str:
sections = [
INTRO,
"## `[bar]`\n\nHow to reach the bar.\n\n" + table(BarConfig),
"## `[sync]`\n\nHow the calendars are read.\n\n" + table(SyncConfig),
"## `[display]`\n\nWhat the bar shows.\n\n" + table(DisplayConfig),
"## `[defaults.reminder]`\n\nThe countdown before an event: the settings the bar's "
"Google Calendar integration has per calendar. They layer: `[defaults.reminder]`, "
"then a source's `[sources.reminder]`, then one calendar's "
'`[sources.calendars."<name or id>".reminder]`, then the `[[titles]]` rules that '
"match the event's title.\n\n" + table(ReminderSettings),
"## `[defaults.status]`\n\nWhat the bar shows while an event is on. It layers the "
"same way: `[defaults.status]`, `[sources.status]`, "
'`[sources.calendars."<name or id>".status]`, `[[titles]]`.\n\n' + table(StatusSettings),
"## `[[sources]]`\n\nOne CalDAV account or one ICS link each; at least one.\n\n"
+ table(SourceConfig, skip=("reminder", "status")),
TITLES + table(TitleRule, skip=("reminder", "status")),
]
return "\n\n".join(section.rstrip() for section in sections) + "\n"
if __name__ == "__main__":
print(markdown(), end="")src/busybar_calendar/render.py
"""
The drawing for a screen.
The front panel is 72x16. A reminder is a 16x16 calendar icon with the
event's title beside it and a countdown to the start. The status during an
event is the same with a countdown to the end and a red BUSY beside it, or
one of the bar's own 72x16 status animations. The icon is the bar's own
calendar animation or a red calendar drawn inline, per `[display]`. An event
you marked tentative gets a yellow calendar and a yellow BUSY instead.
Every element carries `display_until`, so the bar takes the drawing down by
itself even if this service has stopped - a minute after the screen ends
rather than on the second, because this service redraws or clears the same
elements right then, and a redraw that lands as the old element expires
loses its animation on firmware 1.2.4 (measured: 0 of 256 icon pixels lit;
with the expiry a few seconds later, the icon stays).
"""
from __future__ import annotations
import unicodedata
from typing import Annotated, Any, Literal, Protocol
from anyascii import anyascii
from busylib import types
from pydantic import BaseModel, Field
from . import icons
from .config import DisplayConfig
from .events import UNTITLED
from .schedule import Screen
WIDTH = 72
ICON = 16
TEXT_X = ICON + 2
REMINDER_ANIMATION = "shared/animations/calendar_reminder_16x16.anim"
EVENT_ANIMATION = "shared/animations/calendar_event_16x16.anim"
HIDDEN_TITLE = "Next event"
# What an event you marked tentative is drawn in; see `[display] tentative_yellow`.
TENTATIVE_COLOR = "#FFC800FF"
# How long a drawing outlives its screen if nothing replaces it; see above.
EXPIRY_GRACE = 60
DRAW_PATH = "/api/display/draw"
# Rough glyph advance per font, only to decide whether a line must scroll.
CHAR_WIDTH = {"tiny": 4, "small": 4, "global": 6}
# The countdown is drawn in the 7px superscript font; room for "1:05:00".
COUNTDOWN_WIDTH = 28
# The title's top_left y, calibrated in busylib: small glyphs land on rows 2-6.
TITLE_TOP_Y = {"tiny": 1, "small": 0}
# The superscript font sits its digits 4px above the bottom of its box, so a
# box anchored at y=16 puts them on rows 7-11, into the title's descenders.
# Anchored at 19 they land on rows 10-14, level with the icon's last row.
# Measured on firmware 1.2.4 from GET /api/screen.
COUNTDOWN_BOTTOM_Y = 19
# Beside an 11px global-font title, centred on the same rows (6-10).
COUNTDOWN_MID_Y = 9
# The BUSY label, in the small font on the countdown's rows (10-14): 8px
# below the title's anchor, whose glyphs start 2px down.
LABEL_TOP_Y = 8
SCROLL_RATE = 1200
SCROLL_START_DELAY = 1500
SCROLL_REPEAT_DELAY = 2500
class XpmBitmapElement(types.DisplayElementBase):
"""An inline XPM2 image: in the bar's API, but not yet among busylib's models."""
type: Literal["xpmbitmap"] = "xpmbitmap"
data: str
opacity: int = Field(default=100, ge=0, le=100)
Element = Annotated[
types.TextElement | types.AnimationElement | types.CountdownElement | XpmBitmapElement,
Field(discriminator="type"),
]
class Drawing(BaseModel):
"""The body of POST /api/display/draw."""
application_name: str = Field(min_length=1, max_length=32)
priority: int = Field(ge=1, le=100)
elements: list[Element] = Field(min_length=1)
def payload(self) -> dict[str, Any]:
return self.model_dump(mode="json", exclude_none=True)
class Poster(Protocol):
async def api_request(self, method: str, path: str, **kwargs: Any) -> Any: ...
async def send(bar: Poster, drawing: Drawing) -> None:
"""Draw on the bar - on the LAN or through BUSY Cloud, whichever `bar` reaches."""
await bar.api_request("POST", DRAW_PATH, json_payload=drawing.payload())
def _drawable(c: str) -> bool:
# No font has control characters, emoji or other pictographs; and
# transliterated, an emoji becomes ":coffee:".
category = unicodedata.category(c)
return category[0] != "C" and category != "So" and not "︀" <= c <= "️"
# A longer title only scrolls longer; and a draw request carrying a title of
# thousands of characters left a bar's HTTP API hanging (firmware 1.2.4,
# reached on the LAN and through BUSY Cloud alike) until it was power-cycled.
MAX_TITLE = 120
def title_text(title: str, display: DisplayConfig) -> str:
"""The title as the chosen font can draw it, and no longer than MAX_TITLE."""
title = "".join(" " if c.isspace() else c for c in title if c.isspace() or _drawable(c))
if display.title_font != "global":
if display.transliterate:
title = anyascii(title)
title = "".join(c for c in title if " " <= c <= "~")
title = " ".join(title.split()) or UNTITLED
if len(title) > MAX_TITLE:
title = title[: MAX_TITLE - 3].rstrip() + "..."
return title
def _text(
element_id: str, text: str, font: str, x: int, y: int, align: str, available: int, until: str
) -> types.TextElement:
scroll = len(text) * CHAR_WIDTH.get(font, 5) > available
return types.TextElement(
id=element_id,
text=text,
font=font,
x=x,
y=y,
align=align,
display_until=until,
width=available if scroll else None,
scroll_rate=SCROLL_RATE if scroll else None,
scroll_start_delay=SCROLL_START_DELAY if scroll else None,
scroll_repeat_delay=SCROLL_REPEAT_DELAY if scroll else None,
)
def _tentative(screen: Screen, display: DisplayConfig) -> bool:
return display.tentative_yellow and screen.event.response == "tentative"
def _icon(choice: str, animation: str, until: str, tentative: bool) -> Element:
# Different ids for the two kinds: a draw updates elements by id, and an
# element is not turned from one kind into the other. The yellow calendar
# has an id of its own so that going from one colour to the other redraws
# the bitmap rather than relying on the bar to swap an element's pixels.
if choice == "red_calendar":
return XpmBitmapElement(
id="cal_xpm_maybe" if tentative else "cal_xpm",
data=icons.YELLOW_CALENDAR if tentative else icons.RED_CALENDAR,
x=0,
y=0,
align="top_left",
display_until=until,
)
return types.AnimationElement(
id="cal_icon",
stock_path=animation,
loop=True,
x=0,
y=0,
align="top_left",
display_until=until,
)
def _titled(
screen: Screen, icon: Element, title: str, display: DisplayConfig, until: str
) -> list[Element]:
"""A calendar icon, a title, a countdown to `screen.until` and, during an event, BUSY."""
font = display.title_font
if font == "global":
# 11px leaves no second line: title and countdown share one.
available = WIDTH - TEXT_X - COUNTDOWN_WIDTH - 1
title_at = (TEXT_X, 8, "mid_left")
countdown_at = (WIDTH - 1, COUNTDOWN_MID_Y, "mid_right")
else:
available = WIDTH - TEXT_X
title_at = (TEXT_X, TITLE_TOP_Y[font], "top_left")
countdown_at = (TEXT_X, COUNTDOWN_BOTTOM_Y, "bottom_left")
elements: list[Element] = [
icon,
_text("cal_title", title, font, *title_at, available, until),
types.CountdownElement(
id="cal_time",
timestamp=str(int(screen.until.timestamp())),
direction="time_left",
show_hours="when_non_zero",
x=countdown_at[0],
y=countdown_at[1],
align=countdown_at[2],
display_until=until,
),
]
# Beside a global-font title there is no room left for the label.
if screen.kind == "status" and display.busy_label and font != "global":
elements.append(
types.TextElement(
id="cal_busy",
text=display.busy_label,
font="small",
color=TENTATIVE_COLOR if _tentative(screen, display) else display.busy_color,
x=WIDTH,
y=LABEL_TOP_Y,
align="top_right",
display_until=until,
)
)
return elements
def elements(screen: Screen, display: DisplayConfig) -> list[Element]:
until = str(int(screen.until.timestamp()) + EXPIRY_GRACE)
if screen.kind == "reminder":
title = (
title_text(screen.event.title, display) if screen.reminder.show_name else HIDDEN_TITLE
)
icon = _icon(display.reminder_icon, REMINDER_ANIMATION, until, _tentative(screen, display))
return _titled(screen, icon, title, display, until)
if screen.status.show == "name":
icon = _icon(display.event_icon, EVENT_ANIMATION, until, _tentative(screen, display))
return _titled(screen, icon, title_text(screen.event.title, display), display, until)
return [
types.AnimationElement(
id="cal_status",
stock_path=f"shared/animations/{screen.status.show}_72x16.anim",
loop=True,
x=0,
y=0,
align="top_left",
display_until=until,
)
]
def drawing(
screen: Screen, display: DisplayConfig, application_name: str, priority: int
) -> Drawing:
return Drawing(
application_name=application_name,
priority=priority,
elements=elements(screen, display),
)
def sound(screen: Screen, display: DisplayConfig) -> str | None:
"""The stock sound for a screen appearing, if its settings want one."""
if screen.kind == "reminder":
return display.reminder_sound if screen.reminder.sound else None
return display.event_sound if screen.status.sound else Nonesrc/busybar_calendar/runner.py
"""
The loop: refresh the sources, decide the screen, draw it when it changes.
The loop sleeps until the next moment the screen can change - a reminder
opening, an event starting or ending - or the next refresh, whichever is
first, so a reminder appears on the second rather than on the next poll.
A source that fails for a while keeps its last good events, so a calendar
server hiccup does not take a running meeting off the bar. One that answers
401, 403 or 404 is configured wrong, and the loop stops and says so rather
than retrying it forever. A bar that refuses a
drawing (unreachable, or something above our priority on screen) is tried
again shortly.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Awaitable, Callable, Sequence
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from busylib import types
from busylib.exceptions import BusyBarAPIError
from . import render, schedule
from .config import Config, ReminderSettings, StatusSettings
from .events import Event
from .sources import FatalSourceError, Source
logger = logging.getLogger(__name__)
# Upper bound on a sleep, so a laptop waking up catches up within a minute.
MAX_SLEEP = timedelta(seconds=60)
RETRY = timedelta(seconds=15)
# A sound plays only for a screen that has just opened: restarting this
# service in the middle of a meeting must not chime again.
SOUND_GRACE = timedelta(seconds=60)
# Events still running were in the past when they started; look back far
# enough to see any of them (longer ones count as all-day and are skipped).
LOOKBACK = timedelta(hours=24)
_UNKNOWN = object()
class Bar(Protocol):
async def api_request(self, method: str, path: str, **kwargs: Any) -> Any: ...
async def display_clear(self, **kwargs: Any) -> types.SuccessResponse: ...
async def audio_play(self, *, stock_path: str | None = None, **kwargs: Any) -> Any: ...
class Runner:
def __init__(
self,
config: Config,
bar: Bar,
sources: Sequence[Source],
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.config = config
self.bar = bar
self.sources = list(sources)
self.now = now
self._events: dict[str, list[Event]] = {}
self._settings: dict[tuple[str, ...], tuple[ReminderSettings, StatusSettings]] = {}
self._shown: object = _UNKNOWN
# The element ids this service last left on the bar; None when unknown.
self._drawn: frozenset[str] | None = None
self._sounded: set[tuple[str, str]] = set()
self._next_refresh = datetime.min.replace(tzinfo=UTC)
@property
def app(self) -> str:
return self.config.bar.application_name
@property
def events(self) -> list[Event]:
return [event for events in self._events.values() for event in events]
def settings_for(self, event: Event) -> tuple[ReminderSettings, StatusSettings]:
key = (event.calendar.source, event.calendar.name, event.calendar.id, event.title)
if key not in self._settings:
self._settings[key] = self.config.settings_for(*key)
return self._settings[key]
async def refresh(self, now: datetime) -> None:
self._settings.clear() # keyed by title too: keep only the current events'
start = now - LOOKBACK
end = now + timedelta(hours=self.config.sync.lookahead_hours)
results = await asyncio.gather(
*(source.fetch(start, end) for source in self.sources), return_exceptions=True
)
for result in results:
if isinstance(result, FatalSourceError):
raise result # wrong credentials or URL: say so and stop
for source, result in zip(self.sources, results, strict=True):
if isinstance(result, BaseException):
kept = len(self._events.get(source.name, []))
logger.warning(
"%s: refresh failed (%s); keeping %d events", source.name, result, kept
)
continue
self._events[source.name] = result
logger.debug("%s: %d events", source.name, len(result))
# Forget the chimes of events that are gone, or a service that runs for
# months keeps every screen it ever showed.
current = {event.key for event in self.events}
self._sounded = {moment for moment in self._sounded if moment[1] in current}
def reconfigure(self, config: Config, sources: Sequence[Source]) -> None:
"""
Carry on with an edited config: read the calendars again at once, and
redraw what is up - without a second chime for a screen that had one.
Events already read are kept for the sources still configured, should
the first read with the new config fail.
"""
names = {source.name for source in sources}
self._events = {name: found for name, found in self._events.items() if name in names}
self.config = config
self.sources = list(sources)
self._settings.clear()
self._next_refresh = datetime.min.replace(tzinfo=UTC)
self._shown = _UNKNOWN
def _opened_at(self, screen: schedule.Screen) -> datetime:
if screen.kind == "status":
return screen.event.start
return screen.event.start - timedelta(minutes=screen.reminder.minutes_before)
async def clear(self) -> None:
"""Remove this service's drawing, and only this service's."""
try:
await self.bar.display_clear(application_name=self.app)
except BusyBarAPIError as error:
# The bar answers 400 when this application has nothing on
# screen - another app owns the canvas - which is already clear.
if error.status_code != 400:
raise
self._drawn = frozenset()
async def _draw(self, drawing: render.Drawing) -> None:
"""
Put a drawing up in place of the last one.
A draw merges into what the application already shows, by element id:
it updates the ids it names and leaves the others up. So clear first
only when something would be left over - a reminder's elements under
a status animation - and otherwise update in place, which does not
flicker: a reminder becoming its event only adds BUSY.
"""
ids = frozenset(element.id for element in drawing.elements)
if self._drawn is None or not self._drawn <= ids:
await self.clear()
self._drawn = None # unknown until the draw is confirmed
await render.send(self.bar, drawing)
self._drawn = ids
async def show(self, screen: schedule.Screen, *, chime: bool) -> None:
"""Put a screen on the bar, with its sound if `chime` and its settings want one."""
await self._draw(
render.drawing(screen, self.config.display, self.app, self.config.bar.priority)
)
logger.info(
"%s: %s (%s, %s) until %s",
screen.kind,
screen.event.title,
screen.event.calendar.name,
screen.event.response,
screen.until.isoformat(),
)
sound = render.sound(screen, self.config.display) if chime else None
if sound is None:
return
try:
await self.bar.audio_play(stock_path=sound, application_name=self.app)
except Exception as error: # a missing sound is not worth a redraw
logger.warning("could not play %s: %s", sound, error)
async def tick(self, now: datetime) -> None:
screen = schedule.choose(now, self.events, self.settings_for)
key = screen.key if screen is not None else None
if key == self._shown:
return
try:
if screen is None:
await self.clear()
logger.info("cleared")
else:
# Chime once per screen, and only one that has just opened:
# restarting in the middle of a meeting must not chime again.
moment = (screen.kind, screen.event.key)
fresh = now - self._opened_at(screen) <= SOUND_GRACE
await self.show(screen, chime=fresh and moment not in self._sounded)
self._sounded.add(moment)
self._shown = key
except Exception as error:
logger.warning("bar refused the update (%s); retrying", error)
self._shown = _UNKNOWN
self._drawn = None
async def run(
self, stop: asyncio.Event, before_each: Callable[[], Awaitable[None]] | None = None
) -> None:
"""Until `stop`; `before_each` runs at every wake, at least once a minute."""
try:
while not stop.is_set():
if before_each is not None:
await before_each()
now = self.now()
if now >= self._next_refresh:
await self.refresh(now)
self._next_refresh = now + timedelta(minutes=self.config.sync.poll_minutes)
await self.tick(now)
wake = min(now + MAX_SLEEP, self._next_refresh)
change = schedule.next_change(now, self.events, self.settings_for)
if change is not None:
wake = min(wake, change)
if self._shown is _UNKNOWN:
wake = min(wake, now + RETRY)
timeout = max(0.2, (wake - self.now()).total_seconds())
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout)
finally:
try:
await self.clear()
except Exception as error:
logger.warning("could not clear the bar on exit: %s", error)src/busybar_calendar/schedule.py
"""
What the bar should show at a given moment, and when that next changes.
Each event can put up two screens: a reminder from `minutes_before` until it
starts, then its status until it ends. When several want the bar at once:
1. the event you are surest to attend wins: accepted, then tentative, then
not answered yet;
2. between those, the newest event, as in the Google Calendar integration -
so a reminder for your next meeting replaces the status of the one still
running;
3. between events starting together, the calendar configured last.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal
from .config import ReminderSettings, StatusSettings
from .events import RESPONSE_RANK, Event
SettingsFor = Callable[[Event], tuple[ReminderSettings, StatusSettings]]
@dataclass(frozen=True)
class Screen:
kind: Literal["reminder", "status"]
event: Event
reminder: ReminderSettings
status: StatusSettings
@property
def until(self) -> datetime:
return self.event.start if self.kind == "reminder" else self.event.end
@property
def key(self) -> tuple:
"""Equal keys draw the same thing, so nothing needs redrawing."""
return (
self.kind,
self.event.key,
self.event.title,
self.event.end,
self.event.response,
self.reminder,
self.status,
)
def shows_status(event: Event, status: StatusSettings) -> bool:
"""Whether the event puts its status up while it is on."""
return status.enabled and (status.tentative or event.response != "tentative")
def _reminder_from(event: Event, reminder: ReminderSettings) -> datetime:
return event.start - timedelta(minutes=reminder.minutes_before)
def choose(now: datetime, events: Iterable[Event], settings_for: SettingsFor) -> Screen | None:
best: Screen | None = None
best_rank: tuple | None = None
for event in events:
reminder, status = settings_for(event)
if shows_status(event, status) and event.start <= now < event.end:
screen = Screen("status", event, reminder, status)
elif reminder.enabled and _reminder_from(event, reminder) <= now < event.start:
screen = Screen("reminder", event, reminder, status)
else:
continue
rank = (RESPONSE_RANK[event.response], event.start, event.calendar.order, event.key)
if best_rank is None or rank > best_rank:
best, best_rank = screen, rank
return best
def next_change(
now: datetime, events: Iterable[Event], settings_for: SettingsFor
) -> datetime | None:
"""The first moment after `now` at which `choose` could answer differently."""
upcoming = []
for event in events:
reminder, _ = settings_for(event)
for moment in (_reminder_from(event, reminder), event.start, event.end):
if moment > now:
upcoming.append(moment)
return min(upcoming, default=None)
def upcoming(
now: datetime, events: Iterable[Event], settings_for: SettingsFor, horizon: int = 500
) -> tuple[datetime, Screen] | None:
"""The next moment a screen opens or gives way to another, and that screen."""
events = list(events)
current = choose(now, events, settings_for)
moment: datetime | None = now
for _ in range(horizon):
moment = next_change(moment, events, settings_for)
if moment is None:
return None
screen = choose(moment, events, settings_for)
if screen is not None and (current is None or screen.key != current.key):
return moment, screen
return Nonesrc/busybar_calendar/service.py
"""
Run the service in the background: a launchd agent on macOS, a systemd user
unit on Linux.
The service is started as `<this python> -m busybar_calendar run`, so it runs
from the same environment `install` was run from - a `uv tool` or a virtualenv
- and with $BUSYBAR_CALENDAR_CONFIG set to the config `install` found, so it
does not depend on a working directory. Restarts are throttled: a calendar
that refuses the password stops the service, and it must not then hammer the
server every few seconds.
"""
from __future__ import annotations
import os
import plistlib
import re
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from . import update
from .config import CONFIG_ENV
LABEL = "app.busybar.calendar"
UNIT = "busybar-calendar.service"
RESTART_AFTER = 300 # seconds between restarts after the service stops
GONE_WITHIN = 10.0 # seconds to wait for an unloaded launch agent to be gone
@dataclass(frozen=True)
class Plan:
"""What install writes and runs, so it can be shown before it is done."""
path: Path
content: bytes
commands: list[list[str]] # must succeed
cleanup: tuple[tuple[str, ...], ...] = () # run first, may fail: nothing to stop is fine
log: Path | None = None
# Succeeds while the cleaned-up service is still there; commands wait until it fails.
gone: tuple[str, ...] = ()
AUTO_UPDATE = "--auto-update"
# Where a service finds the tools it runs - git for uv, `security` for the
# Keychain - behind the folder uv is in when it updates itself.
SYSTEM_PATH = ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin")
def program(auto_update: bool = False) -> list[str]:
return [sys.executable, "-m", "busybar_calendar", "run", *([AUTO_UPDATE] * auto_update)]
def _environment(config: Path, path: str | None) -> dict[str, str]:
return {CONFIG_ENV: str(config), **({"PATH": path} if path else {})}
def update_path() -> str:
"""The PATH a self-updating service needs: uv's folder first. Refuses an install
that cannot update itself, rather than one that fails at every start."""
uv = shutil.which("uv")
if uv is None:
raise OSError(f"{AUTO_UPDATE} runs `uv tool upgrade`, and there is no uv on PATH")
if not update.from_git(sys.executable):
raise OSError(
f"{AUTO_UPDATE} needs the uv tool installed from git - "
"uv tool install git+https://github.com/kurok/busybar-calendar - "
f"and this runs from {sys.executable}"
)
folders = [str(Path(uv).parent), *SYSTEM_PATH]
return os.pathsep.join(dict.fromkeys(folders))
def launchd_plist(command: list[str], config: Path, log: Path, path: str | None = None) -> bytes:
return plistlib.dumps(
{
"Label": LABEL,
"ProgramArguments": command,
"EnvironmentVariables": _environment(config, path),
"RunAtLoad": True,
"KeepAlive": True,
"ThrottleInterval": RESTART_AFTER,
"StandardOutPath": str(log),
"StandardErrorPath": str(log),
}
)
def _systemd_quote(value: str) -> str:
"""systemd's own quoting: double quotes, with backslash escapes inside."""
if value and not any(c.isspace() or c in '"\\' for c in value):
return value
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def systemd_unit(command: list[str], config: Path, path: str | None = None) -> bytes:
environment = "".join(
f"Environment={_systemd_quote(f'{key}={value}')}\n"
for key, value in _environment(config, path).items()
)
return (
"[Unit]\n"
"Description=BUSY Bar calendar reminders\n"
"After=network-online.target\n\n"
"[Service]\n"
f"{environment}"
f"ExecStart={' '.join(_systemd_quote(arg) for arg in command)}\n"
"Restart=on-failure\n"
f"RestartSec={RESTART_AFTER}\n\n"
"[Install]\n"
"WantedBy=default.target\n"
).encode()
def _home() -> Path:
return Path.home()
def install_plan(config: Path, platform: str = sys.platform, auto_update: bool = False) -> Plan:
"""What `install` does; with `auto_update`, the service updates itself at each start."""
config = config.resolve()
path_env = update_path() if auto_update else None
command = program(auto_update)
if platform == "darwin":
path = _home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
log = _home() / "Library" / "Logs" / "busybar-calendar.log"
domain = f"gui/{os.getuid()}"
return Plan(
path,
launchd_plist(command, config, log, path_env),
[["launchctl", "bootstrap", domain, str(path)]],
cleanup=(("launchctl", "bootout", f"{domain}/{LABEL}"),), # an older one
log=log,
gone=("launchctl", "print", f"{domain}/{LABEL}"),
)
if platform.startswith("linux"):
path = _home() / ".config" / "systemd" / "user" / UNIT
return Plan(
path,
systemd_unit(command, config, path_env),
[
["systemctl", "--user", "daemon-reload"],
["systemctl", "--user", "enable", "--now", UNIT],
],
)
raise OSError(f"install supports macOS and Linux, not {platform}; see docs/setup.md")
def uninstall_plan(platform: str = sys.platform) -> Plan:
if platform == "darwin":
path = _home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
return Plan(
path, b"", [], cleanup=(("launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"),)
)
if platform.startswith("linux"):
path = _home() / ".config" / "systemd" / "user" / UNIT
return Plan(
path,
b"",
[["systemctl", "--user", "daemon-reload"]],
cleanup=(("systemctl", "--user", "disable", "--now", UNIT),),
)
raise OSError(f"uninstall supports macOS and Linux, not {platform}")
def _cleanup(plan: Plan) -> None:
for command in plan.cleanup:
subprocess.run(list(command), capture_output=True, check=False)
def _wait_until_gone(gone: tuple[str, ...], within: float = GONE_WITHIN) -> None:
"""
`launchctl bootout` returns while the agent is still going away, and loading
it again before it has gone fails ("Bootstrap failed: 5: Input/output
error"): so a reinstall or restart waits for it to be gone.
"""
if not gone:
return
deadline = time.monotonic() + within
while subprocess.run(list(gone), capture_output=True, check=False).returncode == 0:
if time.monotonic() >= deadline:
return # loading it says what is wrong
time.sleep(0.25)
def install(plan: Plan) -> None:
plan.path.parent.mkdir(parents=True, exist_ok=True)
if plan.log is not None:
plan.log.parent.mkdir(parents=True, exist_ok=True)
_cleanup(plan)
_wait_until_gone(plan.gone)
plan.path.write_bytes(plan.content)
for command in plan.commands:
subprocess.run(command, check=True)
def uninstall(plan: Plan) -> bool:
"""Stop and remove the service; False if there was none."""
if not plan.path.exists():
return False
_cleanup(plan)
plan.path.unlink()
for command in plan.commands:
subprocess.run(command, check=True)
return True
@dataclass(frozen=True)
class Status:
installed: bool # the agent / unit file is there
running: bool
pid: int | None = None
detail: str = "" # why it is not running, when that is known
log: Path | None = None
def parse_launchctl(text: str) -> dict[str, str]:
"""The job's own fields from `launchctl print`: one tab deep; nested blocks go deeper."""
fields: dict[str, str] = {}
for line in text.splitlines():
if line.startswith("\t") and not line.startswith("\t\t") and " = " in line:
key, _, value = line.strip().partition(" = ")
fields.setdefault(key, value)
return fields
def parse_systemctl(text: str) -> dict[str, str]:
return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
def status(platform: str = sys.platform) -> Status:
plan = uninstall_plan(platform)
installed = plan.path.exists()
if platform == "darwin":
log = _home() / "Library" / "Logs" / "busybar-calendar.log"
done = subprocess.run(
["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"],
capture_output=True,
text=True,
check=False,
)
if done.returncode != 0:
return Status(installed, False, detail="not loaded" if installed else "", log=log)
fields = parse_launchctl(done.stdout)
running = fields.get("state") == "running"
pid = int(fields["pid"]) if fields.get("pid", "").isdigit() else None
exit_code = fields.get("last exit code", "")
detail = "" if running else f"last exit code {exit_code}" if exit_code else ""
return Status(installed, running, pid, detail, log)
done = subprocess.run(
[
"systemctl",
"--user",
"show",
UNIT,
"-p",
"ActiveState",
"-p",
"MainPID",
"-p",
"ExecMainStatus",
],
capture_output=True,
text=True,
check=False,
)
fields = parse_systemctl(done.stdout)
running = fields.get("ActiveState") == "active"
pid = int(fields["MainPID"]) if fields.get("MainPID", "0") not in ("", "0") else None
exit_code = fields.get("ExecMainStatus", "")
detail = "" if running else f"last exit code {exit_code}" if exit_code not in ("", "0") else ""
return Status(installed, running, pid, detail)
def recent_log(state: Status, lines: int = 5) -> list[str]:
"""The service's last few log lines, best effort."""
if state.log is not None:
try:
return state.log.read_text(errors="replace").splitlines()[-lines:]
except OSError:
return []
done = subprocess.run(
["journalctl", "--user", "-u", UNIT, "-n", str(lines), "--no-pager", "-o", "cat"],
capture_output=True,
text=True,
check=False,
)
return done.stdout.splitlines()[-lines:] if done.returncode == 0 else []
def restart(platform: str = sys.platform) -> bool:
"""Restart an installed service, at once; False if none is installed.
On macOS the agent is unloaded and loaded again: `kickstart -k` would wait
out ThrottleInterval - five minutes after the last start - before starting
it, and fails for an agent that is not loaded.
"""
path = uninstall_plan(platform).path
if not path.exists():
return False
if platform == "darwin":
domain = f"gui/{os.getuid()}"
subprocess.run(["launchctl", "bootout", f"{domain}/{LABEL}"], capture_output=True)
_wait_until_gone(("launchctl", "print", f"{domain}/{LABEL}"))
command = ["launchctl", "bootstrap", domain, str(path)]
else:
command = ["systemctl", "--user", "restart", UNIT]
return subprocess.run(command, capture_output=True, check=False).returncode == 0
def installed_command(platform: str = sys.platform) -> list[str]:
"""The command the installed service starts, read from its own file; [] if unknown."""
path = uninstall_plan(platform).path
try:
if platform == "darwin":
return list(plistlib.loads(path.read_bytes())["ProgramArguments"])
for line in path.read_text().splitlines():
if line.startswith("ExecStart="):
return shlex.split(line.removeprefix("ExecStart="))
except (OSError, ValueError, KeyError, TypeError):
return []
return []
def installed_python(platform: str = sys.platform) -> str | None:
"""The interpreter the installed service starts; None if unknown."""
return next(iter(installed_command(platform)), None)
def auto_updates(platform: str = sys.platform) -> bool:
"""Whether the installed service updates itself as it starts."""
return AUTO_UPDATE in installed_command(platform)
STARTED = re.compile(r"busybar-calendar (\S+) started")
def running_version(state: Status) -> str | None:
"""The version the service last started as, from its own log; None if unknown."""
if state.log is not None:
try:
text = state.log.read_text(errors="replace")
except OSError:
return None
else:
done = subprocess.run(
["journalctl", "--user", "-u", UNIT, "-n", "2000", "--no-pager", "-o", "cat"],
capture_output=True,
text=True,
check=False,
)
text = done.stdout if done.returncode == 0 else ""
found = STARTED.findall(text)
return found[-1] if found else Nonesrc/busybar_calendar/sources.py
"""
Configured sources, each turned into a list of events for a time window.
A CalDAV source is one account with several calendars; an ICS source is a
single published feed and counts as one calendar named after the source.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Sequence
from datetime import datetime, tzinfo
from fnmatch import fnmatchcase
import httpx
from . import events
from .caldav import CalDAVAuthError, CalDAVClient, CalDAVError, RemoteCalendar
from .config import SourceConfig
logger = logging.getLogger(__name__)
USER_AGENT = "busybar-calendar"
# An ICS feed is the whole calendar, history included; refuse the absurd.
MAX_FEED_BYTES = 32 * 1024 * 1024
# Calendars are listed again after this many refreshes, to pick up new ones.
REDISCOVER_EVERY = 12
# Answers that mean the source is configured wrong - credentials, URL - so
# asking again will not help; anything else (timeouts, 5xx) is retried.
FATAL_STATUS = frozenset({401, 403, 404, 405, 410})
class FatalSourceError(Exception):
"""A source that cannot work as configured. The service stops on one."""
def matches(calendar: RemoteCalendar, pattern: str) -> bool:
"""
Whether a filter entry names this calendar.
An entry is a calendar's display name, its id (the last segment of its
URL) or its URL, and may use the wildcards * and ?: "Office - Room *".
"""
values = (calendar.name, calendar.id, calendar.url, calendar.url.rstrip("/"))
return any(fnmatchcase(value, pattern) for value in values)
def select(
calendars: Sequence[RemoteCalendar], include: Sequence[str], exclude: Sequence[str]
) -> tuple[list[RemoteCalendar], list[str]]:
"""
The calendars to use, in overlap order, and the entries that matched nothing.
With `include`, calendars come in the order their entries are listed, so
the one listed last wins when two events start together; without it, all
of them in the server's order. `exclude` then removes calendars.
"""
if include:
chosen: list[RemoteCalendar] = []
for pattern in include:
chosen += [c for c in calendars if c not in chosen and matches(c, pattern)]
else:
chosen = list(calendars)
chosen = [c for c in chosen if not any(matches(c, pattern) for pattern in exclude)]
unmatched = [
pattern
for pattern in (*include, *exclude)
if not any(matches(calendar, pattern) for calendar in calendars)
]
return chosen, unmatched
def http_client(source: SourceConfig) -> httpx.AsyncClient:
auth: httpx.Auth | None = None
password = source.password.resolve()
if source.username is not None and password is not None:
auth = (
httpx.DigestAuth(source.username, password)
if source.auth == "digest"
else httpx.BasicAuth(source.username, password)
)
return httpx.AsyncClient(
auth=auth,
follow_redirects=True,
timeout=httpx.Timeout(30.0),
headers={"User-Agent": USER_AGENT},
)
class Source:
def __init__(self, config: SourceConfig, index: int, floating_tz: tzinfo, skip_free: bool):
self.config = config
self.name = config.name
self._index = index
self._floating_tz = floating_tz
self._skip_free = skip_free
def _ref(self, calendar: str, position: int, calendar_id: str = "") -> events.CalendarRef:
return events.CalendarRef(self.name, calendar, self._index * 1000 + position, calendar_id)
def _parse(self, data, ref, start, end) -> list[events.Event]:
return events.parse(
data,
ref,
start,
end,
floating_tz=self._floating_tz,
skip_free=self._skip_free,
emails=self.config.emails,
)
async def fetch(self, start: datetime, end: datetime) -> list[events.Event]:
raise NotImplementedError
async def aclose(self) -> None:
pass
class ICSSource(Source):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._http = http_client(self.config)
self._etag: str | None = None
self._body: bytes | None = None
async def fetch(self, start: datetime, end: datetime) -> list[events.Event]:
headers = {"If-None-Match": self._etag} if self._etag and self._body else {}
response = await self._http.get(self.config.url, headers=headers)
if response.status_code in FATAL_STATUS:
raise FatalSourceError(
f"{self.name}: {self.config.url} answered {response.status_code}"
)
if response.status_code != 304:
response.raise_for_status()
if len(response.content) > MAX_FEED_BYTES:
raise ValueError(f"{self.name}: feed is over {MAX_FEED_BYTES} bytes")
self._body = response.content
self._etag = response.headers.get("ETag")
assert self._body is not None
return self._parse(self._body, self._ref(self.name, 0), start, end)
async def aclose(self) -> None:
await self._http.aclose()
class CalDAVSource(Source):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._http = http_client(self.config)
self._client = CalDAVClient(self._http, self.config.url)
self._calendars: list[RemoteCalendar] = []
self._since_listed = 0
self._unmatched: list[str] = []
async def calendars(self) -> list[RemoteCalendar]:
"""Every event calendar on the account, before `include` and `exclude`."""
return await self._client.discover()
def _keep_known(self, error: Exception) -> None:
"""Listing the calendars failed for now: go on with the ones known, if any."""
if not self._calendars:
raise error
logger.warning(
"%s: could not list the calendars (%s); going on with the ones it knows",
self.name,
error,
)
async def _selected(self) -> list[tuple[int, RemoteCalendar]]:
if not self._calendars or self._since_listed >= REDISCOVER_EVERY:
try:
self._calendars = await self.calendars()
self._since_listed = 0
except CalDAVError as error:
# No status: discovery found no calendar where it was pointed.
if error.status is None or error.status in FATAL_STATUS:
raise FatalSourceError(f"{self.name}: {error}") from error
self._keep_known(error)
except httpx.HTTPError as error:
self._keep_known(error)
self._since_listed += 1
selected, unmatched = select(self._calendars, self.config.include, self.config.exclude)
if unmatched != self._unmatched:
if unmatched:
logger.warning("%s: no calendar matches %s", self.name, ", ".join(unmatched))
self._unmatched = unmatched
return list(enumerate(selected))
async def fetch(self, start: datetime, end: datetime) -> list[events.Event]:
selected = await self._selected()
async def one(position: int, calendar: RemoteCalendar) -> list[events.Event]:
try:
documents = await self._client.events(calendar, start, end)
except CalDAVAuthError as error:
raise FatalSourceError(f"{self.name}: {error}") from error
ref = self._ref(calendar.name, position, calendar.id)
return self._parse(documents, ref, start, end)
results = await asyncio.gather(*(one(i, c) for i, c in selected))
return [event for result in results for event in result]
async def aclose(self) -> None:
await self._http.aclose()
def build(config: SourceConfig, index: int, floating_tz: tzinfo, skip_free: bool) -> Source:
kind = CalDAVSource if config.type == "caldav" else ICSSource
return kind(config, index, floating_tz, skip_free)src/busybar_calendar/update.py
"""
Updating the install the service runs from, as it starts: `run --auto-update`.
Only a uv tool installed from git moves forward by itself: `uv tool upgrade`
fetches its branch again, while one installed from a wheel file would
reinstall that same file. The update runs before the config is read, so a
config that uses something newer gets the version that understands it.
After an update the process replaces itself with the same command, so the
new code runs at once; it does not update again in that second run.
"""
from __future__ import annotations
import contextlib
import importlib
import json
import logging
import os
import shutil
import subprocess
import sys
import tomllib
from importlib import metadata
from pathlib import Path
from typing import NoReturn
logger = logging.getLogger(__name__)
DIST = "busybar-calendar"
# At login the network can take a while to come up; past this, start as is.
TIMEOUT = 120
# Set for the process an update restarts into, so it does not update again.
UPDATED_ENV = "BUSYBAR_CALENDAR_UPDATED"
def upgrade_command(uv: str = "uv") -> list[str]:
return [uv, "tool", "upgrade", DIST]
def from_git(python: str) -> bool:
"""Whether `python` is a uv tool installed from git, which `uv tool upgrade` moves forward."""
receipt = Path(python).parent.parent / "uv-receipt.toml"
with contextlib.suppress(OSError, ValueError, LookupError, TypeError):
return "git" in tomllib.loads(receipt.read_text())["tool"]["requirements"][0]
return False
def installed() -> str:
"""The version on disk now, with its git commit when it came from git; not
necessarily the code this process runs."""
importlib.invalidate_caches()
try:
dist = metadata.distribution(DIST)
except metadata.PackageNotFoundError:
return "unknown"
commit = ""
with contextlib.suppress(TypeError, ValueError, LookupError): # no direct_url.json: None
commit = json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"]
return f"{dist.version} ({commit[:7]})" if commit else dist.version
def update() -> bool:
"""`uv tool upgrade` the install this runs from; True when it now holds other code."""
before = installed()
if not from_git(sys.executable):
logger.warning(
"auto-update: only a uv tool installed from git updates itself; staying on %s",
before,
)
return False
uv = shutil.which("uv")
if uv is None:
logger.warning("auto-update: no uv on PATH; staying on %s", before)
return False
try:
done = subprocess.run(
upgrade_command(uv), capture_output=True, text=True, timeout=TIMEOUT, check=False
)
except (OSError, subprocess.TimeoutExpired) as error:
logger.warning("auto-update failed (%s); staying on %s", error, before)
return False
if done.returncode != 0:
lines = done.stderr.strip().splitlines() or [f"exit {done.returncode}"]
logger.warning("auto-update failed (%s); staying on %s", lines[-1], before)
return False
after = installed()
if after == before:
logger.info("auto-update: %s is the latest", after)
return False
logger.info("auto-update: %s -> %s", before, after)
return True
def restart_into_update() -> NoReturn:
"""Replace this process with the same command, which now runs the updated code."""
os.environ[UPDATED_ENV] = "1"
sys.stdout.flush()
sys.stderr.flush()
os.execv(sys.executable, [sys.executable, *sys.orig_argv[1:]])src/busybar_calendar/wizard.py
"""
`busybar-calendar init`: a few questions instead of editing a TOML file.
It asks how to reach the bar and which calendar to read, signs in to list
the calendars, and writes the config. Passwords never pass through this
program: on macOS the Keychain's own prompt (`security ... -w`) reads them,
on Linux libsecret's (`secret-tool store`), and the config only names the
command that reads them back. Without either, the config names an
environment variable to set instead.
"""
from __future__ import annotations
import asyncio
import json
import shutil
import subprocess
import sys
import tomllib
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from zoneinfo import ZoneInfo
from . import sources
from .caldav import CalDAVError, RemoteCalendar
from .config import Config, ConfigError, SourceConfig, load
Ask = Callable[[str], str]
USB_HOST = "10.0.4.20"
# Servers to suggest by the address's domain; anything else is asked for.
@dataclass(frozen=True)
class Provider:
label: str
url: str = "" # empty: asked for
ask_for: str = "" # what to ask, when the URL depends on the user's server
note: str = "" # said before the password is asked for
domains: tuple[str, ...] = () # email domains that suggest it
PROVIDERS = (
Provider(
"iCloud",
"https://caldav.icloud.com",
note="iCloud needs an app-specific password: account.apple.com -> Sign-In and "
"Security -> App-Specific Passwords.",
domains=("icloud.com", "me.com", "mac.com"),
),
Provider(
"Fastmail",
"https://caldav.fastmail.com",
note="Fastmail needs an app password: Settings -> Privacy & Security.",
domains=("fastmail.com", "fastmail.fm"),
),
Provider("PrivateEmail", "https://dav.privateemail.com"),
Provider("Nextcloud", ask_for="Your Nextcloud's address, e.g. https://cloud.example.com"),
Provider("Another server", ask_for="The CalDAV server's URL"),
)
NAMES = {
"PrivateEmail": "PrivateEmail (Namecheap)",
"Nextcloud": "Nextcloud: asks for your server",
"Another server": "Another server: Radicale, Baikal, Synology, Zoho, ... asks for its URL",
}
def _server(ask: Ask, domain: str) -> tuple[str, Provider]:
"""Pick the provider - guessed from the address - and the server URL it implies."""
guess = next((n for n, p in enumerate(PROVIDERS, 1) if domain in p.domains), len(PROVIDERS))
number = _choose(
ask, "Which provider?", [NAMES.get(p.label, p.label) for p in PROVIDERS], guess
)
provider = PROVIDERS[number - 1]
if provider.url:
return provider.url, provider
answer = _text(ask, provider.ask_for).rstrip("/")
if not answer.startswith(("http://", "https://")):
answer = "https://" + answer
if provider.label == "Nextcloud" and not answer.endswith("/remote.php/dav"):
answer += "/remote.php/dav"
return answer, provider
@dataclass
class Secret:
"""How the config gets a secret back: a command, or an environment variable."""
command: list[str] | None = None
env: str | None = None
def toml(self, key: str) -> str:
if self.command:
return f"{key}_command = {json.dumps(self.command, ensure_ascii=False)}"
return f"{key}_env = {json.dumps(self.env)}"
class SecretStore:
"""Saves a secret with the system's own prompt, so it never passes through here."""
def __init__(self, platform: str = sys.platform) -> None:
self.platform = platform
def save(self, service: str, account: str, env: str) -> Secret:
if self.platform == "darwin" and shutil.which("security"):
print("The Keychain asks for it twice (nothing shows as you type):")
self._store(
["security", "add-generic-password", "-U", "-s", service, "-a", account, "-w"]
)
return Secret(command=["security", "find-generic-password", "-s", service, "-w"])
if self.platform.startswith("linux") and shutil.which("secret-tool"):
label = f"busybar-calendar {service}"
self._store(["secret-tool", "store", "--label", label, "service", service])
return Secret(command=["secret-tool", "lookup", "service", service])
print(f"No keychain here: set {env} in the environment the service runs in.")
return Secret(env=env)
@staticmethod
def _store(command: list[str], attempts: int = 3) -> None:
"""Run the keychain's own prompt; ask again when it fails, say a mistyped retype."""
for attempt in range(1, attempts + 1):
if subprocess.run(command, check=False).returncode == 0:
return
if attempt < attempts:
print("That was not stored (did the two entries differ?). Once more:")
raise ConfigError(f"{command[0]} did not store the password; run the command again")
def _choose(ask: Ask, question: str, options: list[str], default: int = 1) -> int:
print(question)
for number, option in enumerate(options, 1):
print(f" {number} {option}")
while True:
answer = ask(f"Choose 1-{len(options)} [{default}]: ").strip() or str(default)
if answer.isdigit() and 1 <= int(answer) <= len(options):
return int(answer)
print(" Please type one of the numbers.")
def _text(ask: Ask, question: str, default: str = "") -> str:
while True:
prompt = f"{question} [{default}]: " if default else f"{question}: "
answer = ask(prompt).strip() or default
if answer:
return answer
@dataclass
class Answers:
bar_lines: list[str] = field(default_factory=list)
source_lines: list[str] = field(default_factory=list)
def toml(self) -> str:
return "\n".join(
[
"# Written by `busybar-calendar init`. Every setting: config.example.toml.",
"",
"[bar]",
*self.bar_lines,
"",
"[[sources]]",
*self.source_lines,
"",
]
)
def ask_bar(ask: Ask, store: SecretStore, answers: Answers) -> None:
how = _choose(
ask,
"How should it reach the BUSY Bar?",
[
"BUSY Cloud: works from anywhere; needs a token from your BUSY account "
'("BUSY Bar" access scope)',
"Wi-Fi: the bar's address; needs its HTTP API enabled, with a password",
f"USB cable: {USB_HOST}, no password",
],
)
if how == 3:
answers.bar_lines.append(f"address = {json.dumps(USB_HOST)}")
return
if how == 2:
address = _text(ask, "The bar's address (Settings -> Wi-Fi -> View IP Address)")
answers.bar_lines.append(f"address = {json.dumps(address)}")
print("Its HTTP API password (32 characters at most):")
else:
print("The BUSY Cloud token:")
secret = store.save("busybar-calendar-bar", "busybar-calendar", "BUSYBAR_TOKEN")
answers.bar_lines.append(secret.toml("token"))
def _unique(name: str, taken: frozenset[str]) -> str:
number, candidate = 2, name
while candidate in taken:
candidate, number = f"{name} {number}", number + 1
return candidate
def ask_calendar(
ask: Ask,
store: SecretStore,
answers: Answers,
discover: Callable[[SourceConfig], list[RemoteCalendar]],
taken: frozenset[str] = frozenset(),
) -> str:
"""Ask for one calendar source, add its lines to `answers`, and return its name."""
kind = _choose(
ask,
"Which calendar?",
[
"A CalDAV account: iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...",
"An ICS / webcal link: Outlook's published calendar, Google's secret address, ...",
],
)
if kind == 2:
url = _text(ask, "The link")
name = _unique("Calendar", taken)
answers.source_lines += [
f"name = {json.dumps(name)}",
'type = "ics"',
f"url = {json.dumps(url)}",
]
return name
username = _text(ask, "Your username, usually the email address")
domain = username.rpartition("@")[2].lower()
url, provider = _server(ask, domain)
if provider.note:
print(provider.note)
print("The calendar password:")
# One Keychain entry per account: two accounts on one domain must not share it.
secret = store.save(f"busybar-calendar-{username}", username, "CALDAV_PASSWORD")
label = provider.label if provider.url else "" # a fixed provider names the source
name = _unique(label or domain or "CalDAV", taken)
lines = [
f"name = {json.dumps(name)}",
'type = "caldav"',
f"url = {json.dumps(url)}",
f"username = {json.dumps(username)}",
secret.toml("password"),
]
fields: dict = {"name": name, "type": "caldav", "url": url, "username": username}
if secret.command:
fields["password_command"] = secret.command
else:
fields["password_env"] = secret.env
source = SourceConfig.model_validate(fields)
try:
found = discover(source)
except (CalDAVError, sources.FatalSourceError, OSError) as error:
print(f"Could not sign in: {error}")
print("The config is written anyway; fix it, then run: busybar-calendar check")
answers.source_lines += lines
return name
print(f"Signed in: {len(found)} calendars.")
for number, calendar in enumerate(found, 1):
print(f" {number:>2} {calendar.name} (id {calendar.id})")
picked = _pick(ask, len(found))
if picked and len(picked) < len(found):
names = [found[i - 1].name for i in picked]
lines.append(f"include = {json.dumps(names, ensure_ascii=False)}")
answers.source_lines += lines
return name
def _pick(ask: Ask, count: int) -> list[int]:
while True:
answer = ask(
"Which to show? Numbers separated by commas, Enter for all "
"(shared calendars such as meeting rooms are better left out): "
).strip()
if not answer:
return []
numbers = [part.strip() for part in answer.split(",") if part.strip()]
if all(n.isdigit() and 1 <= int(n) <= count for n in numbers):
return sorted({int(n) for n in numbers})
print(f" Use numbers from 1 to {count}.")
def _discover(source: SourceConfig) -> list[RemoteCalendar]:
async def run() -> list[RemoteCalendar]:
item = sources.CalDAVSource(source, 0, ZoneInfo("UTC"), True)
try:
return await item.calendars()
finally:
await item.aclose()
return asyncio.run(run())
def run(
path: Path,
*,
ask: Ask = input,
store: SecretStore | None = None,
discover: Callable[[SourceConfig], list[RemoteCalendar]] = _discover,
overwrite: bool = False,
) -> int:
if path.exists() and not overwrite:
answer = ask(f"{path} exists. Replace it? [y/N]: ").strip().lower()
if answer not in ("y", "yes"):
print("Left as it is.")
return 1
store = store or SecretStore()
answers = Answers()
ask_bar(ask, store, answers)
print()
ask_calendar(ask, store, answers, discover)
text = answers.toml()
Config.model_validate(tomllib.loads(text)) # what load() will read, checked before saving
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
path.chmod(0o600)
print(f"\nWrote {path}.")
print("Next: busybar-calendar check, then busybar-calendar install to keep it running.")
return 0
def add(
path: Path,
*,
ask: Ask = input,
store: SecretStore | None = None,
discover: Callable[[SourceConfig], list[RemoteCalendar]] = _discover,
) -> int:
"""Append one more calendar source to an existing config, keeping the rest as it is."""
current = load(path) # also says how to make one, when there is none
existing = path.read_text()
answers = Answers()
taken = frozenset(source.name for source in current.sources)
name = ask_calendar(ask, store or SecretStore(), answers, discover, taken)
text = existing.rstrip("\n") + "\n\n[[sources]]\n" + "\n".join(answers.source_lines) + "\n"
Config.model_validate(tomllib.loads(text)) # the whole file, checked before saving
path.write_text(text)
print(f"\nAdded {name!r} to {path}. Next: busybar-calendar check")
return 0tests/conftest.py
import pytest
from busybar_calendar import service
@pytest.fixture(autouse=True)
def no_real_service_files(tmp_path_factory, monkeypatch):
"""Keep tests off this machine's own launchd agent or systemd unit."""
home = tmp_path_factory.mktemp("home")
monkeypatch.setattr(service, "_home", lambda: home)tests/test_app.py
"""app.py, the community-gallery entrypoint at the repository root."""
import importlib.util
from pathlib import Path
import pytest
from busybar_calendar.config import CONFIG_ENV, ConfigError
ROOT = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location("gallery_app", ROOT / "app.py")
app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(app)
ICS = '[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://example.com/a.ics"\n'
@pytest.fixture
def no_config(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
monkeypatch.delenv(CONFIG_ENV, raising=False)
monkeypatch.delenv("BUSYBAR_TOKEN", raising=False)
monkeypatch.chdir(tmp_path)
return tmp_path
def config_for(argv):
args = app.parse_args(argv)
path = app.config_file(args)
return path, app.build_config(args, path)
def test_gallery_defaults_without_a_config(no_config):
path, config = config_for([])
assert path is None
assert config.bar.address == "10.0.4.20" # the USB bar
assert config.bar.application_name == "busybar-calendar"
assert config.sources == [] # so main() plays the demo
def test_the_config_init_wrote_is_used(no_config, monkeypatch):
written = no_config / "xdg" / "busybar-calendar" / "config.toml"
written.parent.mkdir(parents=True)
written.write_text(ICS)
path, config = config_for(["--host", "192.168.1.50"])
assert path == written
assert config.sources[0].name == "Feed"
assert config.bar.address == "192.168.1.50" # --host still wins
def test_calendar_flags_win_over_a_config(no_config):
(no_config / "config.toml").write_text(ICS)
path, config = config_for(["--ics", "webcal://example.com/b.ics", "--minutes", "10"])
assert path is None
assert config.sources[0].url == "https://example.com/b.ics"
assert config.settings_for("ics", "ics")[0].minutes_before == 10
def test_caldav_flags_read_the_password_from_the_environment(no_config):
_, config = config_for(
["--caldav", "https://dav.example.com", "--user", "me@example.com", "--include", "Work"]
)
source = config.sources[0]
assert (source.password.env, source.include) == ("CALDAV_PASSWORD", ["Work"])
def test_cloud_needs_a_token(no_config, monkeypatch):
with pytest.raises(ConfigError, match="BUSYBAR_TOKEN"):
config_for(["--cloud", "--ics", "https://example.com/a.ics"])
monkeypatch.setenv("BUSYBAR_TOKEN", "t")
_, config = config_for(["--cloud", "--ics", "https://example.com/a.ics"])
assert config.bar.address is None and config.bar.token.resolve() == "t"
# Runs app.py, and sends SIGINT to itself the moment app.py imports its package:
# mid-import, before any of the app's own handling exists - whatever the
# machine's speed, unlike a timed signal.
INTERRUPT_MID_IMPORT = """
import builtins, os, runpy, signal, sys
sys.argv = [sys.argv[1], "--demo", "--host", "127.0.0.1:9"]
real_import = builtins.__import__
def interrupting_import(name, *args, **kwargs):
if name == "busybar_calendar":
builtins.__import__ = real_import
os.kill(os.getpid(), signal.SIGINT)
return real_import(name, *args, **kwargs)
builtins.__import__ = interrupting_import
runpy.run_path(sys.argv[0], run_name="__main__")
"""
def _interrupt(command, after):
import signal
import subprocess
import time
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if after:
time.sleep(after)
proc.send_signal(signal.SIGINT)
_, err = proc.communicate(timeout=10)
return proc.returncode, err
def test_ctrl_c_while_starting_ends_it_cleanly(no_config):
"""The gallery's checker sends SIGINT and expects exit 0 and no traceback, also
when it lands mid-import, as on the slow first start after an install."""
import sys
code, err = _interrupt([sys.executable, "-c", INTERRUPT_MID_IMPORT, str(ROOT / "app.py")], 0)
assert code == 0, err
assert "Traceback" not in err
def test_ctrl_c_while_running_clears_and_ends_it_cleanly(no_config):
import sys
# The demo against a bar that is not there keeps trying until stopped.
command = [sys.executable, str(ROOT / "app.py"), "--demo", "--host", "127.0.0.1:9"]
code, err = _interrupt(command, 1.5)
assert code == 0, err
assert "Traceback" not in err
def test_bad_flags_fail_at_start(no_config):
from pydantic import ValidationError
with pytest.raises(ValidationError, match="minutes_before"):
config_for(["--ics", "https://example.com/a.ics", "--minutes", "0"])tests/test_caldav.py
"""CalDAV discovery and queries against a fake server shaped like iCloud's."""
from datetime import UTC, datetime
import httpx
import pytest
from busybar_calendar.caldav import CalDAVAuthError, CalDAVClient, CalDAVError, RemoteCalendar
EVENT = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:x\r\nSUMMARY:Sync & plan\r\n"
"DTSTART:20261013T090000Z\r\nDTEND:20261013T093000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
def multistatus(*responses: str) -> str:
body = "".join(f"<d:response>{r}</d:response>" for r in responses)
return (
'<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" '
f'xmlns:c="urn:ietf:params:xml:ns:caldav">{body}</d:multistatus>'
)
def ok(href: str, props: str, missing: str = "") -> str:
out = f"<d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop>"
out += "<d:status>HTTP/1.1 200 OK</d:status></d:propstat>"
if missing:
out += f"<d:propstat><d:prop>{missing}</d:prop>"
out += "<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>"
return out
class FakeServer:
"""caldav.example redirects /.well-known; the account lives on p01-caldav.example."""
def __init__(self, status: int = 207) -> None:
self.status = status
self.reports: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
if self.status != 207:
return httpx.Response(self.status)
if url == "https://caldav.example/":
return httpx.Response(405)
if url == "https://caldav.example/.well-known/caldav":
# A 302, as PrivateEmail and many others send: an HTTP client that
# turns it into a GET would get this server's 405 below.
return httpx.Response(302, headers={"Location": "/dav/"})
if url == "https://caldav.example/dav/" and request.method != "PROPFIND":
return httpx.Response(405)
if url == "https://caldav.example/dav/":
return httpx.Response(
207,
text=multistatus(
ok(
"/dav/",
"<d:current-user-principal><d:href>https://p01-caldav.example/123/principal/"
"</d:href></d:current-user-principal>",
)
),
)
if url == "https://p01-caldav.example/123/principal/":
return httpx.Response(
207,
text=multistatus(
ok(
"/123/principal/",
"<c:calendar-home-set><d:href>/123/calendars/</d:href></c:calendar-home-set>",
)
),
)
if url == "https://p01-caldav.example/123/calendars/" and request.method == "PROPFIND":
return httpx.Response(
207,
text=multistatus(
ok("/123/calendars/", "<d:resourcetype><d:collection/></d:resourcetype>"),
ok(
"/123/calendars/work/",
"<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>"
"<d:displayname>Work</d:displayname><c:supported-calendar-component-set>"
'<c:comp name="VEVENT"/></c:supported-calendar-component-set>',
),
ok(
"/123/calendars/tasks/",
"<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>"
"<d:displayname>Reminders</d:displayname><c:supported-calendar-component-set>"
'<c:comp name="VTODO"/></c:supported-calendar-component-set>',
),
ok(
"/123/calendars/home/",
"<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>",
missing="<d:displayname/>",
),
),
)
if request.method == "REPORT" and url == "https://p01-caldav.example/123/calendars/work/":
assert request.headers["Depth"] == "1"
self.reports.append(request.content.decode())
return httpx.Response(
207,
text=multistatus(
ok("/123/calendars/work/x.ics", f"<c:calendar-data>{EVENT}</c:calendar-data>"),
ok("/123/calendars/work/empty.ics", "<c:calendar-data/>"),
),
)
if url == "https://caldav.example/direct/" and request.method == "PROPFIND":
return httpx.Response(
207,
text=multistatus(
ok(
"/direct/",
"<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>"
"<d:displayname>Direct</d:displayname>",
)
),
)
return httpx.Response(404)
def client(server: FakeServer, url: str) -> CalDAVClient:
http = httpx.AsyncClient(transport=httpx.MockTransport(server), follow_redirects=True)
return CalDAVClient(http, url)
async def test_discovers_through_well_known_and_another_host():
calendars = await client(FakeServer(), "https://caldav.example/").discover()
assert calendars == [
RemoteCalendar("https://p01-caldav.example/123/calendars/work/", "Work"),
RemoteCalendar(
"https://p01-caldav.example/123/calendars/home/",
"https://p01-caldav.example/123/calendars/home/",
),
]
async def test_direct_calendar_url():
calendars = await client(FakeServer(), "https://caldav.example/direct/").discover()
assert calendars == [RemoteCalendar("https://caldav.example/direct/", "Direct")]
async def test_report_with_time_range():
server = FakeServer()
work = RemoteCalendar("https://p01-caldav.example/123/calendars/work/", "Work")
documents = await client(server, "https://caldav.example/").events(
work, datetime(2026, 10, 12, tzinfo=UTC), datetime(2026, 10, 13, 12, tzinfo=UTC)
)
assert len(documents) == 1
assert "SUMMARY:Sync & plan" in documents[0]
assert 'start="20261012T000000Z" end="20261013T120000Z"' in server.reports[0]
async def test_auth_failure_is_reported_not_retried_elsewhere():
with pytest.raises(CalDAVAuthError):
await client(FakeServer(status=401), "https://caldav.example/").discover()
async def test_nothing_to_discover():
with pytest.raises(CalDAVError, match="current-user-principal"):
await client(FakeServer(), "https://nowhere.example/").discover()
async def test_errors_carry_their_status():
with pytest.raises(CalDAVAuthError) as caught:
await client(FakeServer(status=401), "https://caldav.example/").discover()
assert caught.value.status == 401tests/test_cli.py
import asyncio
import json
import sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
from busylib import types
from busylib.exceptions import BusyBarAPIError
from busybar_calendar import cli, connect, diagnose
from busybar_calendar import config as config_module
from busybar_calendar.config import CONFIG_ENV, Config, ConfigError, find
from busybar_calendar.events import CalendarRef, Event
SOURCES = {"sources": [{"name": "Feed", "type": "ics", "url": "https://x"}]}
def test_find_prefers_flag_then_env_then_cwd_then_config_dir(tmp_path, monkeypatch):
home = tmp_path / "xdg"
(home / "busybar-calendar").mkdir(parents=True)
in_home = home / "busybar-calendar" / "config.toml"
in_home.write_text("")
monkeypatch.setenv("XDG_CONFIG_HOME", str(home))
monkeypatch.delenv(CONFIG_ENV, raising=False)
monkeypatch.chdir(tmp_path)
assert find() == in_home
(tmp_path / "config.toml").write_text("")
assert find() == Path("config.toml")
monkeypatch.setenv(CONFIG_ENV, "/elsewhere.toml")
assert find() == Path("/elsewhere.toml")
assert find("given.toml") == Path("given.toml")
def test_find_says_where_it_looked(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "none"))
monkeypatch.delenv(CONFIG_ENV, raising=False)
monkeypatch.chdir(tmp_path)
with pytest.raises(ConfigError, match="busybar-calendar init"):
find()
class FakeBar:
def __init__(self, refuse=None, mode="disabled"):
self.refuse, self.mode = refuse, mode
async def version(self):
return types.VersionInfo(api_semver="27.5.0")
async def status_firmware(self):
if self.refuse:
raise BusyBarAPIError("Forbidden", status_code=self.refuse)
return types.StatusFirmware(version="1.2.4")
async def access(self):
return types.HttpAccessInfo(mode=self.mode, key_valid=False)
async def aclose(self):
pass
class FakeSource:
name = "Feed"
async def fetch(self, start, end):
return []
async def aclose(self):
pass
async def check(monkeypatch, capsys, bar, data=None):
config = Config.model_validate(data or SOURCES)
monkeypatch.setattr(connect, "make_bar", lambda _config: bar)
monkeypatch.setattr(connect, "make_sources", lambda _config: [FakeSource()])
code = await diagnose.check(config)
return code, capsys.readouterr().out
async def test_check_all_good(monkeypatch, capsys):
code, out = await check(monkeypatch, capsys, FakeBar())
assert code == 0
assert "firmware 1.2.4, API 27.5.0, token accepted" in out
assert "Feed: no events in the next 7 days" in out
assert "All good." in out
async def test_check_explains_a_refused_lan_call(monkeypatch, capsys):
lan = {**SOURCES, "bar": {"address": "192.168.1.50", "token": "x" * 43}}
code, out = await check(monkeypatch, capsys, FakeBar(refuse=403), lan)
assert code == 1
assert "HTTP API over Wi-Fi is disabled" in out
code, out = await check(monkeypatch, capsys, FakeBar(refuse=403, mode="key"), lan)
assert "43 characters" in out and "cloud token" in out
def test_a_missing_config_says_how_to_make_one(tmp_path):
from busybar_calendar.config import load
with pytest.raises(ConfigError, match="busybar-calendar init"):
load(tmp_path / "nope.toml")
async def test_status_without_a_config(monkeypatch, capsys):
from busybar_calendar import service
monkeypatch.setattr(service, "status", lambda: service.Status(False, False))
monkeypatch.setattr(service, "recent_log", lambda state: [])
assert await diagnose.status(None) == 0
out = capsys.readouterr().out
assert "not installed" in out and "busybar-calendar install" in out
assert "busybar-calendar init" in out
async def test_status_shows_now_and_next(monkeypatch, capsys, tmp_path):
from datetime import UTC, datetime, timedelta
from busybar_calendar import service
from busybar_calendar.events import CalendarRef, Event
log = tmp_path / "busybar-calendar.log"
log.write_text("16:40:19 reminder: Design review\n")
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=4321, log=log))
now = datetime.now(UTC)
ref = CalendarRef("Feed", "Feed", 0)
running = Event(
"a", "Design review", now - timedelta(minutes=5), now + timedelta(minutes=25), ref
)
later = Event("b", "Retro", now + timedelta(hours=2), now + timedelta(hours=3), ref)
class Source:
name = "Feed"
async def fetch(self, start, end):
return [running, later]
async def aclose(self):
pass
monkeypatch.setattr(connect, "make_sources", lambda config: [Source()])
monkeypatch.setattr(connect, "make_bar", lambda config: FakeBar())
assert await diagnose.status(Config.model_validate(SOURCES)) == 0
out = capsys.readouterr().out
assert "running, pid 4321" in out
assert "reminder: Design review" in out # the log tail
assert "now status: Design review" in out
assert "next reminder: Retro" in out
def test_version(capsys):
from busybar_calendar import __version__
with pytest.raises(SystemExit) as done:
cli.main(["--version"])
assert done.value.code == 0
assert capsys.readouterr().out.strip() == f"busybar-calendar {__version__}"
assert __version__[0].isdigit() # read from the installed package
async def test_check_as_json(monkeypatch, capsys):
import argparse
config = Config.model_validate(SOURCES)
monkeypatch.setattr(connect, "make_bar", lambda _config: FakeBar())
monkeypatch.setattr(connect, "make_sources", lambda _config: [FakeSource()])
code = await cli.cmd_check(config, argparse.Namespace(json=True)) # through the CLI
result = json.loads(capsys.readouterr().out) # nothing else on stdout
assert code == 0
assert result["ok"] is True
bar, calendar = result["findings"]
assert (bar["area"], bar["firmware"], bar["api"]) == ("bar", "1.2.4", "27.5.0")
assert (calendar["area"], calendar["source"], calendar["events"]) == ("calendar", "Feed", 0)
async def test_status_as_json(monkeypatch, capsys):
from datetime import UTC, datetime, timedelta
from busybar_calendar import service
from busybar_calendar.events import CalendarRef, Event
monkeypatch.setattr(
service, "status", lambda: service.Status(True, False, detail="last exit code 1")
)
monkeypatch.setattr(service, "recent_log", lambda state: ["Feed: 401, check the credentials"])
monkeypatch.setattr(service, "installed_python", lambda: "/opt/venv/bin/python")
now = datetime.now(UTC)
later = Event(
"b",
"Retro",
now + timedelta(hours=2),
now + timedelta(hours=3),
CalendarRef("Feed", "Feed", 0),
"tentative",
)
class Source:
name = "Feed"
async def fetch(self, start, end):
return [later]
async def aclose(self):
pass
monkeypatch.setattr(connect, "make_sources", lambda config: [Source()])
monkeypatch.setattr(connect, "make_bar", lambda config: FakeBar())
await diagnose.status(Config.model_validate(SOURCES), as_json=True)
data = json.loads(capsys.readouterr().out)
assert data["service"] == {
"installed": True,
"running": False,
"pid": None,
"detail": "last exit code 1",
"log": None,
"recent_log": ["Feed: 401, check the credentials"],
"version": None,
"python": "/opt/venv/bin/python",
"auto_update": False,
}
assert data["calendar"]["now"] is None
nxt = data["calendar"]["next"]
assert (nxt["kind"], nxt["title"], nxt["response"]) == ("reminder", "Retro", "tentative")
assert datetime.fromisoformat(nxt["at"]) < datetime.fromisoformat(nxt["start"])
async def test_check_tells_a_refused_token_from_an_unreachable_bar(monkeypatch, capsys):
class RefusingCloud(FakeBar):
async def version(self): # the cloud wants a valid token for every call
raise BusyBarAPIError("Forbidden", status_code=403)
code, out = await check(monkeypatch, capsys, RefusingCloud())
assert code == 1
assert "refused (403)" in out and "cloud token is wrong" in out
assert "unreachable" not in out
class Offline(FakeBar):
async def version(self):
raise ConnectionError("no route to host")
code, out = await check(monkeypatch, capsys, Offline())
assert "unreachable (no route to host)" in out
async def test_check_names_the_calendars_a_typo_could_have_meant(monkeypatch, capsys):
from zoneinfo import ZoneInfo
from busybar_calendar import sources
from busybar_calendar.caldav import RemoteCalendar
data = {
"sources": [{"name": "Work", "type": "caldav", "url": "https://x", "include": ["Wrok"]}]
}
config = Config.model_validate(data)
item = sources.CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
found = [RemoteCalendar("https://x/1/", "Team"), RemoteCalendar("https://x/2/", "Work")]
async def calendars():
return found
async def fetch(start, end):
raise AssertionError("no calendar is used, so nothing should be fetched")
item.calendars, item.fetch = calendars, fetch
monkeypatch.setattr(connect, "make_bar", lambda _config: FakeBar())
monkeypatch.setattr(connect, "make_sources", lambda _config: [item])
assert await diagnose.check(config) == 1
out = capsys.readouterr().out
assert "no calendar matches 'Wrok'; there are: Team, Work" in out
assert "no events" not in out # no false all-clear for a source that reads nothing
await item.aclose()
def test_restart(monkeypatch, capsys):
from busybar_calendar import service
monkeypatch.setattr(service, "restart", lambda: True)
assert cli.main(["restart"]) == 0
assert "Restarted" in capsys.readouterr().out
monkeypatch.setattr(service, "restart", lambda: False)
assert cli.main(["restart"]) == 1
assert "Not installed" in capsys.readouterr().out
def test_bare_command_suggests_first_steps(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "none"))
monkeypatch.delenv("BUSYBAR_CALENDAR_CONFIG", raising=False)
monkeypatch.chdir(tmp_path)
assert cli.main([]) == 0
out = capsys.readouterr().out
assert "No config yet" in out and "busybar-calendar init" in out
def test_bare_command_shows_status_once_configured(tmp_path, monkeypatch, capsys):
config = tmp_path / "config.toml"
config.write_text('[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://x"\n')
seen = []
async def fake_status(loaded, as_json=False, **where):
seen.append(loaded.sources[0].name)
return 0
monkeypatch.setattr(diagnose, "status", fake_status)
assert cli.main(["-c", str(config)]) == 0
assert seen == ["Feed"]
assert "--help lists every command" in capsys.readouterr().out
def test_a_failing_launchctl_is_one_line_not_a_traceback(tmp_path, monkeypatch, capsys):
import subprocess
from busybar_calendar import service
config = tmp_path / "config.toml"
config.write_text('[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://x"\n')
monkeypatch.setattr(service, "_home", lambda: tmp_path)
def refuse(plan):
raise subprocess.CalledProcessError(5, ["launchctl", "bootstrap", "gui/501", "x.plist"])
monkeypatch.setattr(service, "install", refuse)
assert cli.main(["-c", str(config), "install", "--force"]) == 1
err = capsys.readouterr().err
assert "launchctl bootstrap gui/501 x.plist failed (exit 5)" in err
def _config_file(tmp_path, text):
path = tmp_path / "config.toml"
path.write_text(text)
return str(path)
def test_config_mistakes_are_one_line_each(tmp_path, capsys):
path = _config_file(
tmp_path,
'[bar]\nadress = "x"\n[sync]\ntimezone = "Europe/Kiyv"\n'
'[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://x"\n',
)
assert cli.main(["-c", path, "upcoming"]) == 1
err = capsys.readouterr().err.strip().splitlines()
assert "busybar-calendar: bar.adress: Extra inputs are not permitted" in err
assert any(line.startswith("busybar-calendar: sync.timezone: 'Europe/Kiyv'") for line in err)
assert not any("pydantic.dev" in line for line in err)
def test_an_unreachable_server_is_one_line(tmp_path, monkeypatch, capsys):
import httpx
path = _config_file(
tmp_path,
'[[sources]]\nname = "Gone"\ntype = "caldav"\nurl = "https://dav.invalid/"\n',
)
async def unreachable(self):
request = httpx.Request("PROPFIND", "https://dav.invalid/")
raise httpx.ConnectError("name or service not known", request=request)
from busybar_calendar import sources
monkeypatch.setattr(sources.CalDAVSource, "calendars", unreachable)
assert cli.main(["-c", path, "calendars"]) == 1
assert capsys.readouterr().err.strip() == (
"busybar-calendar: cannot reach https://dav.invalid/: name or service not known"
)
async def test_status_says_when_the_service_runs_another_version(monkeypatch, capsys, tmp_path):
from busybar_calendar import __version__, service
log = tmp_path / "busybar-calendar.log"
log.write_text(
"2026-10-01 INFO busybar_calendar: busybar-calendar 0.0.9 started: 1 calendar source(s)\n"
"2026-10-12 INFO busybar_calendar: busybar-calendar 0.1.0 started: 1 calendar source(s)\n"
"2026-10-12 INFO busybar_calendar.runner: cleared\n"
)
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7, log=log))
monkeypatch.setattr(service, "installed_python", lambda: sys.executable) # this install
assert service.running_version(service.Status(True, True, log=log)) == "0.1.0" # the last
await diagnose.status(None)
out = capsys.readouterr().out
assert "Service running 0.1.0, pid 7" in out
assert f"that is 0.1.0, but this is {__version__}" in out and "restart` runs this one" in out
async def test_status_names_the_install_the_service_runs_from(monkeypatch, capsys, tmp_path):
from busybar_calendar import __version__, service
log = tmp_path / "busybar-calendar.log"
log.write_text("x busybar-calendar 0.1.0 started: 1 calendar source(s)\n")
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7, log=log))
# A checkout run with `uv run`, while the service runs the `uv tool` install:
# a restart would start 0.1.0 again, so it must not be offered as the fix.
monkeypatch.setattr(service, "installed_python", lambda: "/opt/tools/old/bin/python")
await diagnose.status(None)
out = capsys.readouterr().out
assert f"that is 0.1.0, from /opt/tools/old, not this {__version__}" in out
assert "upgrade that one, then `busybar-calendar restart`" in out
assert "runs this one" not in out
def test_upgrade_command_only_for_a_uv_tool_from_git(tmp_path):
tool = tmp_path / "tools" / "busybar-calendar"
(tool / "bin").mkdir(parents=True)
python = str(tool / "bin" / "python")
assert diagnose.upgrade_command(python) is None # not a uv tool
receipt = tool / "uv-receipt.toml"
receipt.write_text(
'[tool]\nrequirements = [{ name = "busybar-calendar", '
'git = "https://github.com/kurok/busybar-calendar" }]\n'
)
assert diagnose.upgrade_command(python) == "uv tool upgrade busybar-calendar"
# From a wheel file, `uv tool upgrade` would reinstall the same version.
receipt.write_text(
'[tool]\nrequirements = [{ name = "busybar-calendar", path = "/d/x-0.3.0.whl" }]\n'
)
assert diagnose.upgrade_command(python) is None
receipt.write_text("not toml [")
assert diagnose.upgrade_command(python) is None
async def test_status_quiet_when_versions_match(monkeypatch, capsys, tmp_path):
from busybar_calendar import __version__, service
log = tmp_path / "busybar-calendar.log"
log.write_text(f"x busybar-calendar {__version__} started: 1 calendar source(s)\n")
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7, log=log))
await diagnose.status(None)
out = capsys.readouterr().out
assert f"running {__version__}, pid 7" in out and "but this is" not in out
async def test_status_says_when_the_bar_does_not_answer(monkeypatch, capsys):
import asyncio
from busybar_calendar import service
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7))
monkeypatch.setattr(service, "recent_log", lambda state: [])
monkeypatch.setattr(connect, "make_sources", lambda config: [FakeSource()])
class Hung(FakeBar):
async def version(self):
await asyncio.sleep(60) # a bar whose HTTP API hangs
monkeypatch.setattr(connect, "make_bar", lambda config: Hung())
monkeypatch.setattr(diagnose, "BAR_TIMEOUT", 0.05)
health = await diagnose.bar_health(Config.model_validate(SOURCES), timeout=0.05)
assert health == {"where": "BUSY Cloud", "answering": False, "detail": "no answer"}
data = {
"version": "x",
"service": {
"installed": True,
"running": True,
"pid": 7,
"detail": None,
"log": None,
"recent_log": [],
"version": None,
},
"config": {"path": None, "problems": []},
"bar": health,
"calendar": None,
}
diagnose.print_status(data)
out = capsys.readouterr().out
assert "Bar BUSY Cloud: NOT answering (no answer)" in out
assert "unplug it and plug it back in" in out
async def test_bar_health_cases(monkeypatch):
config = Config.model_validate(SOURCES)
monkeypatch.setattr(connect, "make_bar", lambda config: FakeBar())
assert (await diagnose.bar_health(config))["answering"] is True
class Refusing(FakeBar):
async def version(self):
raise BusyBarAPIError("Forbidden", status_code=403)
monkeypatch.setattr(connect, "make_bar", lambda config: Refusing())
health = await diagnose.bar_health(config)
assert health["answering"] is True and "refused (403)" in health["detail"]
def no_bar(config):
raise ConfigError("give bar.address, or a bar-scope cloud token as bar.token")
monkeypatch.setattr(connect, "make_bar", no_bar)
assert (await diagnose.bar_health(config))["answering"] is False
WATCHED = """\
[bar]
address = "10.0.4.20"
[[sources]]
name = "Work"
type = "ics"
url = "https://example.com/work.ics"
"""
async def test_the_service_follows_config_edits(tmp_path, caplog):
from busybar_calendar.runner import Runner
now = datetime.now(UTC)
path = tmp_path / "config.toml"
path.write_text(WATCHED)
config = config_module.load(path)
runner = Runner(config, bar=None, sources=connect.make_sources(config))
watch = cli.ConfigWatch(path, runner)
try:
await watch() # unchanged: nothing to do
assert runner.config is config
path.write_text(
WATCHED + '[[sources]]\nname = "Home"\ntype = "ics"\nurl = "https://example.com/h.ics"'
'\n\n[[titles]]\nmatch = "Lunch*"\nstatus.show = "lunch"\n'
)
await watch()
assert [s.name for s in runner.sources] == ["Work", "Home"]
assert (
runner.settings_for(Event("u", "Lunch", now, now, CalendarRef("Work", "Work", 0)))[
1
].show
== "lunch"
)
applied = runner.config
path.write_text(WATCHED + '[[titles]]\nmatch = "Lunch"\nstatus.show = "partying"\n')
await watch()
assert runner.config is applied # a broken edit is not applied...
assert "keeps the config it has" in caplog.text # ...and the log says why
assert "titles.0.status: show:" in caplog.text
path.write_text(WATCHED.replace("10.0.4.20", "192.168.1.50"))
await watch()
assert runner.config.bar.address == "10.0.4.20" # [bar] waits for a restart
assert "restart" in caplog.text
assert [s.name for s in runner.sources] == ["Work"] # the rest is applied
path.unlink()
await watch()
assert [s.name for s in runner.sources] == ["Work"]
finally:
await connect.close_all(runner.sources)
def test_status_reports_a_config_that_does_not_load(tmp_path, monkeypatch, capsys):
from busybar_calendar import service
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7))
monkeypatch.setattr(service, "recent_log", lambda state: [])
monkeypatch.setattr(service, "running_version", lambda state: None)
monkeypatch.setattr(service, "installed_python", lambda: None)
path = tmp_path / "config.toml"
path.write_text(WATCHED + '[[titles]]\nmatch = "Lunch"\nstatus.show = "partying"\n')
assert cli.main(["-c", str(path), "status"]) == 1
out = capsys.readouterr().out
assert "Service running, pid 7" in out # the service part still shows
assert f"Config does not load: {path}" in out
assert "the running service keeps the config it last loaded" in out
assert "titles.0.status: show: show must be" in out
assert "no config yet" not in out
assert cli.main(["-c", str(path), "status", "--json"]) == 1
data = json.loads(capsys.readouterr().out)
assert data["config"]["path"] == str(path)
assert data["config"]["problems"][0].startswith("titles.0.status: show:")
assert data["service"]["running"] is True
assert cli.main(["-c", str(path)]) == 1 # the bare command too, not first steps
assert "does not load" in capsys.readouterr().out
def test_status_with_no_config_file_still_says_how_to_make_one(tmp_path, monkeypatch, capsys):
from busybar_calendar import service
monkeypatch.setattr(service, "status", lambda: service.Status(False, False))
monkeypatch.setattr(service, "recent_log", lambda state: [])
assert cli.main(["-c", str(tmp_path / "none.toml"), "status"]) == 0
assert "no config yet" in capsys.readouterr().out
assert cli.main(["-c", str(tmp_path / "none.toml"), "status", "--json"]) == 0
assert json.loads(capsys.readouterr().out)["config"] == {"path": None, "problems": []}
def test_upcoming_notes_say_what_the_bar_does_and_why():
config = Config.model_validate(
{
**SOURCES,
"defaults": {"status": {"tentative": False}},
"titles": [
{"match": "Focus*", "reminder": {"enabled": False}, "status": {"enabled": False}},
{"match": ["Lunch", "Обід*"], "status": {"show": "lunch"}},
{"match": "*", "reminder": {"minutes_before": 2}},
],
}
)
now = datetime.now(UTC)
ref = CalendarRef("Feed", "Feed", 0)
def notes(title, response="accepted"):
return cli.upcoming_notes(config, Event("u", title, now, now, ref, response))
assert notes("Sync") == "reminder 2m, status name, titles '*'"
# Tentative, with status.tentative off: the reminder only, and the note says why.
assert notes("Sync", "tentative") == "reminder 2m, tentative, titles '*'"
assert notes("обід з командою") == "reminder 2m, status lunch, titles 'Обід*' '*'"
config.titles.pop() # without the catch-all, Focus time keeps nothing
assert notes("Focus time") == "hidden, titles 'Focus*'"
def test_run_auto_update_updates_before_reading_the_config(tmp_path, monkeypatch):
from busybar_calendar import update
order = []
class Restarted(Exception):
pass
def restart():
order.append("restart")
raise Restarted # os.execv does not return
monkeypatch.setattr(update, "restart_into_update", restart)
monkeypatch.setattr(update, "update", lambda: order.append("update") or True)
monkeypatch.setenv(update.UPDATED_ENV, "")
command = ["-c", str(tmp_path / "missing.toml"), "run", "--auto-update"]
with pytest.raises(Restarted): # even a config this version cannot read waits
cli.main(command)
assert order == ["update", "restart"]
order.clear()
monkeypatch.setenv(update.UPDATED_ENV, "1") # the process the update restarted into
assert cli.main(command) == 1 # straight on to the config, which is missing
assert order == []
monkeypatch.setenv(update.UPDATED_ENV, "")
monkeypatch.setattr(update, "update", lambda: order.append("update") or False)
assert cli.main(command) == 1 # nothing new: on with the version it has
assert order == ["update"]
assert cli.main(command[:-1]) == 1 # plain `run` does not update
assert order == ["update"]
def test_status_with_a_self_updating_service(monkeypatch, capsys):
from busybar_calendar import service
monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7))
monkeypatch.setattr(service, "recent_log", lambda state: [])
monkeypatch.setattr(service, "running_version", lambda state: "0.3.1")
monkeypatch.setattr(service, "installed_python", lambda: "/opt/tools/old/bin/python")
monkeypatch.setattr(service, "auto_updates", lambda: True)
data = asyncio.run(diagnose.status_data(None))
assert data["service"]["auto_update"] is True
diagnose.print_status(data)
out = capsys.readouterr().out
assert "running 0.3.1, pid 7, updates itself at each start" in out
assert "`busybar-calendar restart` updates it" in out
assert "uv tool upgrade" not in outtests/test_config.py
import pytest
from pydantic import ValidationError
from busybar_calendar.config import Config, ConfigError, load
def base(**source):
return {"sources": [{"name": "Work", "type": "caldav", "url": "https://dav.example", **source}]}
def test_settings_layer_defaults_source_calendar():
config = Config.model_validate(
{
"defaults": {"reminder": {"minutes_before": 3, "sound": False}},
"sources": [
{
"name": "Work",
"type": "caldav",
"url": "https://dav.example",
"reminder": {"minutes_before": 10},
"calendars": {
"Team": {"status": {"show": "meeting"}, "reminder": {"sound": True}}
},
}
],
}
)
reminder, status = config.settings_for("Work", "Personal")
assert (reminder.minutes_before, reminder.sound, status.show) == (10, False, "name")
reminder, status = config.settings_for("Work", "Team")
assert (reminder.minutes_before, reminder.sound, status.show) == (10, True, "meeting")
def test_rejects_unknown_status_and_typos():
with pytest.raises(ValidationError, match="show must be"):
Config.model_validate(base(status={"show": "partying"})).settings_for("Work", "x")
with pytest.raises(ValidationError):
Config.model_validate(base(urll="x"))
def test_load_validates_every_calendar(tmp_path):
path = tmp_path / "c.toml"
path.write_text(
'[[sources]]\nname = "W"\ntype = "caldav"\nurl = "https://x"\n'
"[sources.calendars.Team.reminder]\nminutes_before = 0\n"
)
with pytest.raises(ValidationError, match="minutes_before"):
load(path)
def test_webcal_and_emails():
config = Config.model_validate(
{
"sources": [
{"name": "Feed", "type": "ics", "url": "webcal://cal.example/a.ics"},
{
"name": "Mail",
"type": "caldav",
"url": "https://x",
"username": "me@example.com",
},
]
}
)
assert config.sources[0].url == "https://cal.example/a.ics"
assert config.sources[1].emails == ["me@example.com"]
def test_ics_has_no_calendars():
with pytest.raises(ValidationError, match="caldav only"):
Config.model_validate(
{"sources": [{"name": "Feed", "type": "ics", "url": "https://x", "include": ["a"]}]}
)
def test_unique_source_names():
with pytest.raises(ValidationError, match="unique"):
Config.model_validate(
{
"sources": [
{"name": "A", "type": "ics", "url": "https://x"},
{"name": "A", "type": "ics", "url": "https://y"},
]
}
)
def test_secrets(monkeypatch):
monkeypatch.setenv("APP_PW", "from-env")
config = Config.model_validate(base(username="u", password_env="APP_PW"))
assert config.sources[0].password.resolve() == "from-env"
config = Config.model_validate(base(password_command=["echo", "from-cmd"]))
assert config.sources[0].password.resolve() == "from-cmd"
config = Config.model_validate({**base(), "bar": {"token": "inline"}})
assert config.bar.token.resolve() == "inline"
assert Config.model_validate(base()).bar.token.resolve() is None
monkeypatch.delenv("APP_PW")
config = Config.model_validate(base(password_env="APP_PW"))
with pytest.raises(ConfigError, match="APP_PW"):
config.sources[0].password.resolve()
with pytest.raises(ValidationError, match="only one of"):
Config.model_validate(base(password="a", password_env="APP_PW"))
def test_display_options_are_validated():
from busybar_calendar.config import DisplayConfig
assert DisplayConfig(busy_color="#ff0000").busy_color == "#FF0000FF"
for bad in ({"busy_color": "red"}, {"busy_label": "ЗАЙНЯТО"}, {"event_icon": "blue"}):
with pytest.raises(ValidationError):
DisplayConfig(**bad)
def test_tentative_status_can_be_turned_off():
config = Config.model_validate(base(status={"tentative": False}))
_, status = config.settings_for("Work", "Work")
assert status.tentative is False
def test_yellow_for_tentative_events_is_on_unless_turned_off(tmp_path):
path = tmp_path / "c.toml"
path.write_text(
'[[sources]]\nname = "Work"\ntype = "ics"\nurl = "https://x"\n'
"[display]\ntentative_yellow = false\n"
)
assert load(path).display.tentative_yellow is False
assert Config.model_validate(base()).display.tentative_yellow is True
def test_a_config_file_needs_a_source(tmp_path):
path = tmp_path / "c.toml"
path.write_text('[bar]\naddress = "10.0.4.20"\n')
with pytest.raises(ConfigError, match="no"):
load(path)
def test_events_of_no_source_get_the_defaults():
config = Config.model_validate({"defaults": {"reminder": {"minutes_before": 2}}})
reminder, status = config.settings_for("demo", "demo")
assert (reminder.minutes_before, status.show) == (2, "name")
def test_timezone_is_checked_when_loading():
with pytest.raises(ValidationError, match="not a timezone name"):
Config.model_validate({**base(), "sync": {"timezone": "Europe/Kiyv"}})
assert Config.model_validate({**base(), "sync": {"timezone": "Europe/Kyiv"}})
def test_title_rules_layer_over_the_calendar_in_order():
config = Config.model_validate(
{
**base(calendars={"Team": {"status": {"show": "meeting"}}}),
"titles": [
{"match": "lunch*", "status": {"show": "lunch"}, "reminder": {"sound": False}},
{"match": ["Focus time", "Commute ?"], "reminder": {"enabled": False}},
{"match": "*", "reminder": {"minutes_before": 2}},
],
}
)
reminder, status = config.settings_for("Work", "Team", title="Lunch with Alex")
assert (status.show, reminder.sound, reminder.minutes_before) == ("lunch", False, 2)
reminder, status = config.settings_for("Work", "Team", title="focus TIME")
assert (reminder.enabled, status.show) == (False, "meeting")
reminder, _ = config.settings_for("Work", "Team", title="Commute home")
assert reminder.enabled is True # `?` is one character
_, status = config.settings_for("Work", "Team", title="Sync")
assert status.show == "meeting"
def test_title_rules_need_a_match():
with pytest.raises(ValidationError, match="match"):
Config.model_validate({**base(), "titles": [{"reminder": {"enabled": False}}]})
with pytest.raises(ValidationError, match="match"):
Config.model_validate({**base(), "titles": [{"match": []}]})
def test_a_bad_setting_says_where_it_is(tmp_path):
path = tmp_path / "c.toml"
path.write_text(
'[[sources]]\nname = "Work"\ntype = "ics"\nurl = "https://x"\n'
'[[titles]]\nmatch = "Lunch"\nstatus.show = "partying"\n'
)
with pytest.raises(ValidationError) as caught:
load(path)
(problem,) = caught.value.errors()
assert problem["loc"] == ("titles", 0, "status")
assert "show: show must be" in problem["msg"]
with pytest.raises(ValidationError) as caught:
Config.model_validate(base(calendars={"Team": {"reminder": {"minutes_before": 0}}}))
assert caught.value.errors()[0]["loc"] == ("sources", 0, "calendars", "Team", "reminder")tests/test_docs.py
"""The docs that are generated from the code, or have to stay valid against it."""
from pathlib import Path
import pytest
from busybar_calendar import config, reference
ROOT = Path(__file__).resolve().parent.parent
MODELS = [
config.BarConfig,
config.SyncConfig,
config.DisplayConfig,
config.ReminderSettings,
config.StatusSettings,
config.SourceConfig,
config.TitleRule,
]
def test_configuration_reference_is_current():
committed = (ROOT / "docs" / "configuration.md").read_text()
assert committed == reference.markdown(), (
"docs/configuration.md is out of date: "
"python -m busybar_calendar.reference > docs/configuration.md"
)
@pytest.mark.parametrize("model", MODELS, ids=lambda m: m.__name__)
def test_every_option_is_described(model):
overrides = model in (config.SourceConfig, config.TitleRule)
inherited = {"reminder", "status"} if overrides else set()
missing = [
name
for name, field in model.model_fields.items()
if not field.description and name not in inherited
]
assert not missing, f"{model.__name__}: describe {missing} - it goes into the reference"
def test_example_config_loads():
loaded = config.load(ROOT / "config.example.toml")
assert loaded.sources
DOCS = [ROOT / "README.md", ROOT / "CHANGELOG.md", *sorted((ROOT / "docs").glob("*.md"))]
@pytest.mark.parametrize("doc", DOCS, ids=lambda path: path.name)
def test_relative_links_resolve(doc):
import re
targets = re.findall(r"\]\(([^)#\s]+)(?:#[^)]*)?\)", doc.read_text())
broken = [
target
for target in targets
if not target.startswith(("http://", "https://", "mailto:"))
and not (doc.parent / target).exists()
]
assert not broken, f"{doc.name} links to missing files: {broken}"tests/test_events.py
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from busybar_calendar.events import CalendarRef, parse
REF = CalendarRef("src", "cal", 0)
KYIV = ZoneInfo("Europe/Kyiv")
WINDOW = (datetime(2026, 10, 12, tzinfo=UTC), datetime(2026, 10, 20, tzinfo=UTC))
def ics(*events: str) -> str:
body = "\n".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT" for e in events)
return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:test\n{body}\nEND:VCALENDAR\n".replace(
"\n", "\r\n"
)
def run(data, **kwargs):
kwargs.setdefault("floating_tz", KYIV)
return parse(data, REF, *WINDOW, **kwargs)
def test_timed_event_in_utc():
[event] = run(ics("UID:a\nSUMMARY:Standup\nDTSTART:20261013T090000Z\nDTEND:20261013T091500Z"))
assert event.title == "Standup"
assert event.start == datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
assert event.end == datetime(2026, 10, 13, 9, 15, tzinfo=UTC)
def test_tzid_and_floating_times():
data = ics(
"UID:a\nSUMMARY:Zoned\nDTSTART;TZID=America/New_York:20261013T090000\n"
"DTEND;TZID=America/New_York:20261013T100000",
"UID:b\nSUMMARY:Floating\nDTSTART:20261013T090000\nDTEND:20261013T100000",
)
zoned, floating = sorted(run(data), key=lambda e: e.title, reverse=True)
assert zoned.start == datetime(2026, 10, 13, 13, 0, tzinfo=UTC)
# Kyiv is UTC+3 in October.
assert floating.start == datetime(2026, 10, 13, 6, 0, tzinfo=UTC)
def test_duration_and_no_end():
data = ics(
"UID:a\nSUMMARY:Dur\nDTSTART:20261013T090000Z\nDURATION:PT45M",
"UID:b\nSUMMARY:Point\nDTSTART:20261013T120000Z",
)
by_title = {e.title: e for e in run(data)}
assert (by_title["Dur"].end - by_title["Dur"].start).total_seconds() == 45 * 60
assert by_title["Point"].end == by_title["Point"].start
def test_skips_like_google_and_more():
data = ics(
"UID:allday\nSUMMARY:Holiday\nDTSTART;VALUE=DATE:20261014\nDTEND;VALUE=DATE:20261015",
"UID:long\nSUMMARY:Offsite\nDTSTART:20261014T080000Z\nDTEND:20261015T080000Z",
"UID:cancel\nSUMMARY:Gone\nSTATUS:CANCELLED\nDTSTART:20261014T090000Z\n"
"DTEND:20261014T100000Z",
"UID:free\nSUMMARY:Lunch\nTRANSP:TRANSPARENT\nDTSTART:20261014T110000Z\n"
"DTEND:20261014T120000Z",
"UID:declined\nSUMMARY:Nope\nDTSTART:20261014T130000Z\nDTEND:20261014T140000Z\n"
"ATTENDEE;PARTSTAT=DECLINED:mailto:Me@Example.com\n"
"ATTENDEE;PARTSTAT=ACCEPTED:mailto:other@example.com",
"UID:keep\nSUMMARY:Kept\nDTSTART:20261014T150000Z\nDTEND:20261014T160000Z\n"
"ATTENDEE;PARTSTAT=DECLINED:mailto:other@example.com",
)
titles = [e.title for e in run(data, emails=["me@example.com"])]
assert titles == ["Kept"]
# Free events are shown when asked for.
titles = [e.title for e in run(data, emails=["me@example.com"], skip_free=False)]
assert titles == ["Lunch", "Kept"]
def test_recurrence_with_exdate_and_override():
data = ics(
"UID:r\nSUMMARY:Daily\nDTSTART:20261012T080000Z\nDTEND:20261012T081500Z\n"
"RRULE:FREQ=DAILY;COUNT=5\nEXDATE:20261014T080000Z",
"UID:r\nRECURRENCE-ID:20261015T080000Z\nSUMMARY:Daily (moved)\n"
"DTSTART:20261015T100000Z\nDTEND:20261015T101500Z",
)
events = run(data)
assert [(e.title, e.start.day, e.start.hour) for e in events] == [
("Daily", 12, 8),
("Daily", 13, 8),
("Daily (moved)", 15, 10),
("Daily", 16, 8),
]
assert len({e.key for e in events}) == 4
def test_several_documents_and_a_broken_one():
good = ics("UID:a\nSUMMARY:One\nDTSTART:20261013T090000Z\nDTEND:20261013T100000Z")
other = ics("UID:b\nSUMMARY:Two\nDTSTART:20261013T080000Z\nDTEND:20261013T083000Z")
events = run([good, "not a calendar", other])
assert [e.title for e in events] == ["Two", "One"]
def test_untitled_and_whitespace():
data = ics(
"UID:a\nDTSTART:20261013T090000Z\nDTEND:20261013T100000Z",
"UID:b\nSUMMARY: Sprint\\n review \nDTSTART:20261013T110000Z\nDTEND:20261013T120000Z",
)
assert [e.title for e in run(data)] == ["Busy", "Sprint review"]
ME = ["me@example.com"]
def responses(*attendee_lines, organizer=None, emails=ME):
extra = "\n".join(attendee_lines)
if organizer:
extra += f"\nORGANIZER:mailto:{organizer}"
data = ics(f"UID:a\nSUMMARY:X\nDTSTART:20261013T090000Z\nDTEND:20261013T100000Z\n{extra}")
events = run(data, emails=emails)
return events[0].response if events else None
def test_response_follows_my_attendee_entry():
other = "ATTENDEE;PARTSTAT=ACCEPTED:mailto:other@example.com"
assert responses(other, "ATTENDEE;PARTSTAT=ACCEPTED:mailto:me@example.com") == "accepted"
assert responses(other, "ATTENDEE;PARTSTAT=TENTATIVE:mailto:ME@example.com") == "tentative"
assert responses(other, "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:me@example.com") == (
"needs-action"
)
# RFC 5545: no PARTSTAT means no answer yet.
assert responses(other, "ATTENDEE:mailto:me@example.com") == "needs-action"
assert responses(other, "ATTENDEE;PARTSTAT=DECLINED:mailto:me@example.com") is None
assert responses(other, "ATTENDEE;PARTSTAT=DELEGATED:mailto:me@example.com") is None
def test_my_own_entry_wins_even_when_i_organise():
mine = "ATTENDEE;PARTSTAT=TENTATIVE:mailto:me@example.com"
assert responses(mine, organizer="me@example.com") == "tentative"
def test_response_without_my_entry():
other = "ATTENDEE;PARTSTAT=ACCEPTED:mailto:other@example.com"
assert responses() == "accepted" # no attendees: an event of my own
assert responses(other, organizer="me@example.com") == "accepted"
assert responses(other, organizer="boss@example.com") == "needs-action" # via a group
assert responses(other, emails=[]) == "accepted" # no address to tell by
# Shapes of the published feeds the docs promise to read.
OUTLOOK_FEED = """BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008
SUMMARY:Weekly planning
DTSTART;TZID=W. Europe Standard Time:20261013T100000
DTEND;TZID=W. Europe Standard Time:20261013T110000
RRULE:FREQ=WEEKLY;BYDAY=TU
END:VEVENT
END:VCALENDAR
"""
GOOGLE_FEED = """BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
X-WR-TIMEZONE:Europe/Kyiv
BEGIN:VEVENT
DTSTART:20261013T060000Z
DTEND:20261013T063000Z
UID:abc123@google.com
SUMMARY:Standup
STATUS:CONFIRMED
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
"""
def test_outlook_windows_timezones_across_the_dst_change():
data = OUTLOOK_FEED.replace("\n", "\r\n")
window = (datetime(2026, 10, 12, tzinfo=UTC), datetime(2026, 11, 4, tzinfo=UTC))
# The fallback zone is wrong on purpose: the feed's own VTIMEZONE must win.
events = parse(data, REF, *window, floating_tz=ZoneInfo("America/New_York"))
berlin = ZoneInfo("Europe/Berlin")
assert [e.start.astimezone(berlin).strftime("%d %H:%M") for e in events] == [
"13 10:00",
"20 10:00",
"27 10:00",
"03 10:00", # 10:00 local, before and after DST
]
assert [e.start.hour for e in events] == [8, 8, 9, 9] # in UTC
def test_google_secret_address_feed():
[event] = run(GOOGLE_FEED.replace("\n", "\r\n"))
assert (event.title, event.start) == ("Standup", datetime(2026, 10, 13, 6, 0, tzinfo=UTC))
def test_tentative_marking_counts_when_there_is_no_attendee_entry():
outlook = "X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE"
status = "STATUS:TENTATIVE"
busy = "X-MICROSOFT-CDO-BUSYSTATUS:BUSY"
assert responses(outlook) == "tentative" # a published Outlook feed
assert responses(status) == "tentative"
assert responses(busy) == "accepted"
assert responses(outlook, emails=[]) == "tentative" # no address of mine to look for
# My own attendee entry still decides when there is one.
mine = "ATTENDEE;PARTSTAT=ACCEPTED:mailto:me@example.com"
assert responses(mine, status) == "accepted"tests/test_render.py
import dataclasses
from datetime import UTC, datetime, timedelta
import pytest
from busybar_calendar import icons
from busybar_calendar.config import DisplayConfig, ReminderSettings, StatusSettings
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.render import drawing, sound, title_text
from busybar_calendar.schedule import Screen
START = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
EVENT = Event(
"u", "Планування спринту ☕", START, START + timedelta(minutes=30), CalendarRef("s", "c", 0)
)
def screen(kind="reminder", reminder=None, status=None, response="accepted"):
event = dataclasses.replace(EVENT, response=response)
return Screen(kind, event, reminder or ReminderSettings(), status or StatusSettings())
def by_id(d):
return {e.id: e for e in d.elements}
def test_title_text():
assert title_text("Café ☕️ sync 🚀", DisplayConfig()) == "Cafe sync"
assert title_text("Планування", DisplayConfig()) == "Planuvannya"
assert title_text("Планування", DisplayConfig(transliterate=False)) == "Busy"
assert title_text("Планування\tспринту", DisplayConfig(title_font="global")) == (
"Планування спринту"
)
def test_reminder_two_lines():
d = drawing(screen(), DisplayConfig(), "busybar-calendar", 91)
assert (d.application_name, d.priority) == ("busybar-calendar", 91)
elements = by_id(d)
assert elements["cal_xpm"].data == icons.RED_CALENDAR
assert "cal_busy" not in elements # BUSY is for the event itself, not before it
title = elements["cal_title"]
assert title.text == "Planuvannya sprintu"
assert (title.font, title.align, title.x, title.y) == ("small", "top_left", 18, 0)
assert title.scroll_rate # 19 characters do not fit beside the icon
countdown = elements["cal_time"]
assert countdown.timestamp == str(int(START.timestamp()))
assert (countdown.direction, countdown.align, countdown.y) == ("time_left", "bottom_left", 19)
# The bar takes the reminder down by itself, a grace minute after the start.
assert {e.display_until for e in d.elements} == {str(int(START.timestamp()) + 60)}
def test_short_title_does_not_scroll_and_hidden_name():
short = Screen(
"reminder",
Event("u", "1:1", START, START, CalendarRef("s", "c", 0)),
ReminderSettings(),
StatusSettings(),
)
assert by_id(drawing(short, DisplayConfig(), "a", 91))["cal_title"].scroll_rate is None
hidden = drawing(screen(reminder=ReminderSettings(show_name=False)), DisplayConfig(), "a", 91)
assert by_id(hidden)["cal_title"].text == "Next event"
def test_global_font_shares_one_line():
elements = by_id(drawing(screen(), DisplayConfig(title_font="global"), "a", 91))
assert elements["cal_title"].text == "Планування спринту"
assert (elements["cal_title"].align, elements["cal_title"].y) == ("mid_left", 8)
assert (elements["cal_time"].align, elements["cal_time"].x, elements["cal_time"].y) == (
"mid_right",
71,
9,
)
def test_status_name_counts_to_the_end():
d = drawing(screen("status"), DisplayConfig(), "a", 91)
elements = by_id(d)
end = int(EVENT.end.timestamp())
assert elements["cal_time"].timestamp == str(end)
assert {e.display_until for e in d.elements} == {str(end + 60)}
def test_status_has_the_red_calendar_and_busy():
elements = by_id(drawing(screen("status"), DisplayConfig(), "a", 91))
assert list(elements) == ["cal_xpm", "cal_title", "cal_time", "cal_busy"]
icon = elements["cal_xpm"]
assert (icon.type, icon.data) == ("xpmbitmap", icons.RED_CALENDAR)
busy = elements["cal_busy"]
assert (busy.text, busy.color, busy.font) == ("BUSY", "#FF0000FF", "small")
# Right-aligned on the countdown's line.
assert (busy.align, busy.x, busy.y) == ("top_right", 72, 8)
def test_icons_and_label_follow_the_config():
display = DisplayConfig(event_icon="animation", busy_label="", reminder_icon="animation")
status = by_id(drawing(screen("status"), display, "a", 91))
assert list(status) == ["cal_icon", "cal_title", "cal_time"]
assert status["cal_icon"].stock_path == "shared/animations/calendar_event_16x16.anim"
reminder = by_id(drawing(screen(), display, "a", 91))
assert reminder["cal_icon"].stock_path == "shared/animations/calendar_reminder_16x16.anim"
custom = by_id(
drawing(screen("status"), DisplayConfig(busy_label="IN MTG", busy_color="#FF8800"), "a", 91)
)
assert (custom["cal_busy"].text, custom["cal_busy"].color) == ("IN MTG", "#FF8800FF")
def test_no_busy_label_with_the_global_font():
elements = by_id(drawing(screen("status"), DisplayConfig(title_font="global"), "a", 91))
assert "cal_busy" not in elements
def test_tentative_event_gets_the_yellow_calendar_and_busy():
for kind in ("reminder", "status"):
elements = by_id(drawing(screen(kind, response="tentative"), DisplayConfig(), "a", 91))
assert "cal_xpm" not in elements
icon = elements["cal_xpm_maybe"]
assert (icon.type, icon.data) == ("xpmbitmap", icons.YELLOW_CALENDAR)
assert icon.data != icons.RED_CALENDAR
# The same place and size as the red one: only the colours differ.
red = by_id(drawing(screen(kind), DisplayConfig(), "a", 91))["cal_xpm"]
assert (icon.x, icon.y, icon.align) == (red.x, red.y, red.align)
assert list(elements) == (
["cal_xpm_maybe", "cal_title", "cal_time", "cal_busy"]
if kind == "status"
else ["cal_xpm_maybe", "cal_title", "cal_time"]
)
busy = by_id(drawing(screen("status", response="tentative"), DisplayConfig(), "a", 91))
assert (busy["cal_busy"].text, busy["cal_busy"].color) == ("BUSY", "#FFC800FF")
def test_yellow_is_for_tentative_only():
# Answered "yes", or not at all: the red calendar and the configured BUSY colour.
for response in ("accepted", "needs-action"):
elements = by_id(drawing(screen("status", response=response), DisplayConfig(), "a", 91))
assert elements["cal_xpm"].data == icons.RED_CALENDAR
assert elements["cal_busy"].color == "#FF0000FF"
assert "cal_xpm_maybe" not in elements
def test_yellow_can_be_turned_off():
display = DisplayConfig(tentative_yellow=False)
elements = by_id(drawing(screen("status", response="tentative"), display, "a", 91))
assert elements["cal_xpm"].data == icons.RED_CALENDAR
assert elements["cal_busy"].color == "#FF0000FF"
assert "cal_xpm_maybe" not in elements
def test_yellow_busy_keeps_its_place_and_ignores_the_busy_colour():
display = DisplayConfig(busy_color="#00FF00", busy_label="IN MTG")
busy = by_id(drawing(screen("status", response="tentative"), display, "a", 91))["cal_busy"]
assert (busy.text, busy.color, busy.align, busy.x, busy.y) == (
"IN MTG",
"#FFC800FF",
"top_right",
72,
8,
)
def test_the_bars_own_animations_have_no_yellow_version():
display = DisplayConfig(event_icon="animation", reminder_icon="animation")
for kind, animation in (("reminder", "reminder"), ("status", "event")):
elements = by_id(drawing(screen(kind, response="tentative"), display, "a", 91))
assert (
elements["cal_icon"].stock_path == f"shared/animations/calendar_{animation}_16x16.anim"
)
# The label is still yellow beside the animation.
assert elements["cal_busy"].color == "#FFC800FF"
def test_stock_status_animation_ignores_tentative():
status = StatusSettings(show="meeting")
d = drawing(screen("status", status=status, response="tentative"), DisplayConfig(), "a", 91)
assert [e.id for e in d.elements] == ["cal_status"]
@pytest.mark.parametrize("calendar", ["RED_CALENDAR", "YELLOW_CALENDAR"])
def test_calendars_are_valid_xpm2(calendar):
lines = getattr(icons, calendar).splitlines()
assert lines[0] == "! XPM2"
width, height, colours, cpp = map(int, lines[1].split())
assert (width, height, cpp) == (16, 16, 1)
assert colours <= 32 # the firmware's limit
palette = {line[0] for line in lines[2 : 2 + colours]}
rows = lines[2 + colours :]
assert len(rows) == height
assert all(len(row) == width and set(row) <= palette for row in rows)
def test_status_stock_animation():
d = drawing(screen("status", status=StatusSettings(show="meeting")), DisplayConfig(), "a", 91)
[element] = d.elements
assert element.stock_path == "shared/animations/meeting_72x16.anim"
assert element.loop
def test_sounds_follow_settings():
display = DisplayConfig()
assert sound(screen(), display) == "shared/sounds/calendar_reminder_ends.snd"
assert sound(screen("status"), display) == "shared/sounds/calendar_event_starts.snd"
assert sound(screen(reminder=ReminderSettings(sound=False)), display) is None
assert sound(screen("status", status=StatusSettings(sound=False)), display) is None
def test_payload_matches_the_wire_format():
payload = drawing(screen("status"), DisplayConfig(), "a", 91).payload()
assert {e["type"] for e in payload["elements"]} == {"xpmbitmap", "text", "countdown"}
for element in payload["elements"]:
assert "timeout" not in element # mutually exclusive with display_until
assert element["display_until"].isdigit()
def test_titles_are_capped():
from busybar_calendar.render import MAX_TITLE
long = "Quarterly planning " * 300 # 5700 characters
for font in ("small", "global"):
text = title_text(long, DisplayConfig(title_font=font))
assert len(text) <= MAX_TITLE and text.endswith("...")
assert title_text("Standup", DisplayConfig()) == "Standup" # short ones untouchedtests/test_runner.py
import asyncio
import itertools
from datetime import UTC, datetime, timedelta
from busylib.exceptions import BusyBarAPIError
from busybar_calendar.config import Config
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.runner import Runner
T0 = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
REF = CalendarRef("Work", "Work", 0)
CONFIG = Config.model_validate({"sources": [{"name": "Work", "type": "ics", "url": "https://x"}]})
TITLED = ("draw", ["cal_xpm", "cal_title", "cal_time"]) # the reminder
BUSY = ("draw", ["cal_xpm", "cal_title", "cal_time", "cal_busy"]) # the status
MAYBE = ("draw", ["cal_xpm_maybe", "cal_title", "cal_time"]) # a tentative reminder
MAYBE_BUSY = ("draw", ["cal_xpm_maybe", "cal_title", "cal_time", "cal_busy"]) # its status
CLEAR = ("clear", "busybar-calendar")
class FakeBar:
def __init__(self) -> None:
self.calls: list[tuple] = []
self.fail = 0
self.someone_else_on_screen = False
async def api_request(self, method, path, json_payload=None, **kwargs):
assert (method, path) == ("POST", "/api/display/draw")
if self.fail:
self.fail -= 1
raise ConnectionError("unreachable")
self.calls.append(("draw", [e["id"] for e in json_payload["elements"]]))
async def display_clear(self, **kwargs):
if self.someone_else_on_screen:
raise BusyBarAPIError("Bad Request", status_code=400, method="DELETE")
self.calls.append(("clear", kwargs.get("application_name")))
async def audio_play(self, *, stock_path=None, **kwargs):
self.calls.append(("sound", stock_path.rsplit("/", 1)[-1]))
class FakeSource:
name = "Work"
def __init__(self, events) -> None:
self.events = events
self.fail = False
async def fetch(self, start, end):
if self.fail:
raise OSError("server down")
return list(self.events)
async def aclose(self):
pass
def make(events, config=CONFIG):
bar, source = FakeBar(), FakeSource(events)
return Runner(config, bar, [source]), bar, source
def at(minutes):
return T0 + timedelta(minutes=minutes)
def meeting(start_min=10, length=30, title="Sync", response="accepted", uid="m"):
start = at(start_min)
return Event(uid, title, start, start + timedelta(minutes=length), REF, response)
async def test_full_life_of_an_event():
runner, bar, _ = make([meeting()])
await runner.refresh(at(0))
await runner.tick(at(0))
await runner.tick(at(5))
await runner.tick(at(6)) # unchanged, no redraw
await runner.tick(at(10))
await runner.tick(at(40))
assert bar.calls == [
CLEAR, # whatever an earlier run left behind
TITLED,
("sound", "calendar_reminder_ends.snd"),
BUSY, # the reminder's elements plus BUSY: updated in place, no clear, no flicker
("sound", "calendar_event_starts.snd"),
CLEAR,
]
async def test_tentative_event_is_drawn_yellow_and_updated_in_place():
runner, bar, _ = make([meeting(response="tentative")])
await runner.refresh(at(5))
await runner.tick(at(5))
await runner.tick(at(10))
# Yellow for the reminder and the status alike; the status only adds BUSY.
assert [c for c in bar.calls if c[0] != "sound"] == [CLEAR, MAYBE, MAYBE_BUSY]
async def test_accepted_event_takes_the_bar_from_a_tentative_one_and_gives_it_back():
# Maybe 0-60 is on; Sure 20-50 starts in the middle of it.
runner, bar, _ = make(
[
meeting(0, 60, "Maybe", "tentative", uid="t"),
meeting(20, 30, "Sure", "accepted", uid="a"),
]
)
await runner.refresh(at(10))
for minute in (10, 14, 16, 21, 51, 61):
await runner.tick(at(minute))
assert [c for c in bar.calls if c[0] != "sound"] == [
CLEAR,
MAYBE_BUSY, # 10: the tentative event is on, in yellow
CLEAR, # 15: Sure's reminder takes over - red, so its own element ids
TITLED,
BUSY, # 20: Sure's status
CLEAR, # 50: Sure is over, and the yellow one is back
MAYBE_BUSY,
CLEAR, # 60: nothing left
]
async def test_changing_your_answer_redraws_the_screen():
# Marked "maybe" from the phone while the reminder is up: yellow without waiting.
runner, bar, source = make([meeting(response="accepted")])
await runner.refresh(at(5))
await runner.tick(at(5))
source.events = [meeting(response="tentative")]
await runner.refresh(at(6))
await runner.tick(at(6))
assert [c for c in bar.calls if c[0] != "sound"] == [CLEAR, TITLED, CLEAR, MAYBE]
# Not a second chime for the same reminder.
assert [c for c in bar.calls if c[0] == "sound"] == [("sound", "calendar_reminder_ends.snd")]
async def test_same_elements_update_in_place():
config = Config.model_validate(
{
"display": {"busy_label": ""},
"sources": [{"name": "Work", "type": "ics", "url": "https://x"}],
}
)
runner, bar, _ = make([meeting()], config)
await runner.refresh(at(5))
await runner.tick(at(5))
await runner.tick(at(10))
# Reminder and status share their element ids: no clear, so no flicker.
assert [c for c in bar.calls if c[0] != "sound"] == [CLEAR, TITLED, TITLED]
async def test_first_draw_after_start_clears_leftovers():
runner, bar, _ = make([meeting()])
await runner.refresh(at(20))
await runner.tick(at(20))
# Restarted mid-meeting: no chime, and the old run's drawing goes first.
assert bar.calls == [CLEAR, BUSY]
async def test_layout_change_clears_before_drawing():
config = Config.model_validate(
{
"sources": [
{
"name": "Work",
"type": "ics",
"url": "https://x",
"status": {"show": "meeting", "sound": False},
"reminder": {"sound": False},
}
]
}
)
runner, bar, _ = make([meeting()], config)
await runner.refresh(at(5))
await runner.tick(at(5))
await runner.tick(at(10))
# The meeting animation has other ids than the reminder, which a draw
# would otherwise leave underneath it.
assert bar.calls == [CLEAR, TITLED, CLEAR, ("draw", ["cal_status"])]
async def test_clear_when_another_app_owns_the_screen():
runner, bar, _ = make([meeting()])
bar.someone_else_on_screen = True
await runner.refresh(at(0))
await runner.tick(at(0))
await runner.tick(at(1))
assert bar.calls == [] # 400 means nothing of ours is there: done, no retry loop
bar.someone_else_on_screen = False
await runner.tick(at(5))
assert bar.calls[0] == TITLED # known clear already, so no second clear
async def test_failed_draw_is_retried():
runner, bar, _ = make([meeting()])
bar.fail = 1
await runner.refresh(at(5))
await runner.tick(at(5))
assert bar.calls == [CLEAR]
await runner.tick(at(5.5))
assert bar.calls == [
CLEAR,
CLEAR, # the failed draw left the screen unknown
TITLED,
("sound", "calendar_reminder_ends.snd"),
]
async def test_failed_refresh_keeps_events():
runner, bar, source = make([meeting()])
await runner.refresh(at(0))
source.fail = True
await runner.refresh(at(9))
await runner.tick(at(12))
assert BUSY in bar.calls
async def test_renamed_event_redraws_without_chime():
runner, bar, source = make([meeting()])
await runner.refresh(at(10))
await runner.tick(at(10))
source.events = [meeting(title="Sync (moved room)")]
await runner.refresh(at(12))
await runner.tick(at(12))
assert [c[0] for c in bar.calls] == ["clear", "draw", "sound", "draw"]
async def test_run_wakes_on_time_and_clears_on_stop():
clock = [at(4.999)]
runner, bar, _ = make([meeting()])
runner.now = lambda: clock[0]
stop = asyncio.Event()
task = asyncio.create_task(runner.run(stop))
await asyncio.sleep(0.05)
clock[0] = at(5) # the reminder opens; the loop wakes within its 0.2s floor
await asyncio.sleep(0.4)
stop.set()
await task
assert [c[0] for c in bar.calls] == ["clear", "draw", "sound", "clear"]
async def test_a_source_configured_wrong_stops_the_service():
from busybar_calendar.sources import FatalSourceError
runner, _, source = make([meeting()])
await runner.refresh(at(0))
async def wrong_password(start, end):
raise FatalSourceError("Work: 401, check the credentials")
source.fetch = wrong_password
try:
await runner.refresh(at(1))
except FatalSourceError as error:
assert "401" in str(error)
else:
raise AssertionError("a 401 must stop the service, not be retried forever")
async def test_play_carries_on_when_the_bar_refuses_a_draw(monkeypatch):
from busybar_calendar import playback as cli
from busybar_calendar.playback import play
async def no_wait(stop, moment):
return False
monkeypatch.setattr(cli, "_wait", no_wait)
class Busy409Bar(FakeBar):
async def api_request(self, method, path, json_payload=None, **kwargs):
title = next(e["text"] for e in json_payload["elements"] if e["id"] == "cal_title")
if title == "Second":
raise BusyBarAPIError("Not drawn due to low priority", status_code=409)
return await super().api_request(method, path, json_payload=json_payload, **kwargs)
bar = Busy409Bar()
runner = Runner(CONFIG, bar, [])
events = [meeting(title="First"), meeting(title="Second"), meeting(title="Third")]
await play(runner, events, 1, asyncio.Event(), chime=False)
drawn = [c for c in bar.calls if c[0] == "draw"]
# Second's two screens were refused; First and Third still played in full.
assert len(drawn) == 4
async def test_left_over_elements_are_cleared_first():
# A meeting's status, then the next meeting's reminder: BUSY must not stay up.
runner, bar, _ = make([meeting(start_min=0, length=60, title="Long"), meeting(start_min=30)])
await runner.refresh(at(10))
await runner.tick(at(10))
await runner.tick(at(26))
# (No chime for Long: its status opened ten minutes before the first tick.)
assert bar.calls == [CLEAR, BUSY, CLEAR, TITLED, ("sound", "calendar_reminder_ends.snd")]
async def test_chimes_of_events_that_are_gone_are_forgotten():
runner, _, source = make([meeting()])
await runner.refresh(at(5))
await runner.tick(at(5))
assert runner._sounded == {("reminder", meeting().key)}
source.events = [] # the meeting is over and out of the window
await runner.refresh(at(2000))
assert runner._sounded == set()
async def test_title_rules_keep_an_event_off_the_bar():
config = Config.model_validate(
{
"sources": [{"name": "Work", "type": "ics", "url": "https://x"}],
"titles": [
{"match": "Focus*", "reminder": {"enabled": False}, "status": {"enabled": False}}
],
}
)
runner, bar, _ = make([meeting(title="Focus time"), meeting(60, title="Sync")], config)
await runner.refresh(at(0))
for minute in (6, 20): # the focus block's reminder, then its middle
await runner.tick(at(minute))
assert bar.calls == [CLEAR]
await runner.tick(at(56)) # the Sync reminder still shows, with its sound
assert bar.calls[1:] == [TITLED, ("sound", "calendar_reminder_ends.snd")]
async def test_an_edited_config_redraws_at_once_without_a_second_chime():
runner, bar, _ = make([meeting()])
await runner.refresh(at(10))
await runner.tick(at(10))
assert bar.calls[1:] == [BUSY, ("sound", "calendar_event_starts.snd")]
edited = Config.model_validate(
{
"defaults": {"status": {"show": "meeting"}},
"sources": [{"name": "Work", "type": "ics", "url": "https://x"}],
}
)
fresh = FakeSource([meeting()])
runner.reconfigure(edited, [fresh])
assert runner.sources == [fresh] and runner.config is edited
await runner.refresh(at(10.5))
await runner.tick(at(10.5))
# The bar's own status in place of the titled screen: cleared first, no chime.
assert bar.calls[3:] == [CLEAR, ("draw", ["cal_status"])]
async def test_reconfigure_keeps_the_events_of_sources_still_configured():
runner, _, _ = make([meeting()])
await runner.refresh(at(0))
runner.reconfigure(CONFIG, [FakeSource([])]) # same name: events kept until re-read
assert len(runner.events) == 1
renamed = FakeSource([])
renamed.name = "Home"
runner.reconfigure(CONFIG, [renamed])
assert runner.events == []
async def test_run_calls_its_hook_at_every_wake():
runner, _, _ = make([])
clock = itertools.count()
runner.now = lambda: at(2 * next(clock)) # time flies: every sleep is the 0.2s floor
stop = asyncio.Event()
calls = []
async def hook():
calls.append(1)
if len(calls) == 2:
stop.set()
await asyncio.wait_for(runner.run(stop, hook), 2)
assert calls == [1, 1]tests/test_schedule.py
from datetime import UTC, datetime, timedelta
from busybar_calendar.config import ReminderSettings, StatusSettings
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.schedule import choose, next_change, upcoming
T0 = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
WORK = CalendarRef("icloud", "Work", 0)
HOME = CalendarRef("icloud", "Home", 1)
def event(title, start_min, length_min=30, calendar=WORK):
start = T0 + timedelta(minutes=start_min)
return Event(title, title, start, start + timedelta(minutes=length_min), calendar)
def settings(reminder=None, status=None):
reminder = reminder or ReminderSettings()
status = status or StatusSettings()
return lambda _event: (reminder, status)
def at(minutes):
return T0 + timedelta(minutes=minutes)
def test_reminder_then_status_then_nothing():
events = [event("Sync", 10)]
s = settings()
assert choose(at(0), events, s) is None
assert choose(at(5), events, s).kind == "reminder"
assert choose(at(9.99), events, s).kind == "reminder"
assert choose(at(10), events, s).kind == "status"
assert choose(at(39.99), events, s).kind == "status"
assert choose(at(40), events, s) is None
def test_disabled_settings():
events = [event("Sync", 10)]
assert choose(at(6), events, settings(ReminderSettings(enabled=False))) is None
assert choose(at(12), events, settings(status=StatusSettings(enabled=False))) is None
def test_next_reminder_replaces_running_status():
# The newest event wins: the reminder for 09:30 over the 09:00 meeting.
events = [event("Long", 0, 90), event("Next", 30)]
screen = choose(at(26), events, settings())
assert (screen.kind, screen.event.title) == ("reminder", "Next")
screen = choose(at(31), events, settings())
assert (screen.kind, screen.event.title) == ("status", "Next")
screen = choose(at(70), events, settings())
assert (screen.kind, screen.event.title) == ("status", "Long")
def test_same_start_later_calendar_wins():
events = [event("Home thing", 0, calendar=HOME), event("Work thing", 0, calendar=WORK)]
assert choose(at(1), events, settings()).event.title == "Home thing"
def test_screen_keys_change_with_what_is_drawn():
events = [event("Sync", 10)]
s = settings()
assert choose(at(5), events, s).key == choose(at(6), events, s).key
assert choose(at(5), events, s).key != choose(at(10), events, s).key
renamed = [Event("Sync", "Renamed", events[0].start, events[0].end, WORK)]
assert choose(at(5), events, s).key != choose(at(5), renamed, s).key
def test_next_change():
events = [event("Sync", 10), event("Later", 120)]
s = settings(ReminderSettings(minutes_before=10))
assert next_change(at(-30), events, s) == at(0)
assert next_change(at(0), events, s) == at(10)
assert next_change(at(10), events, s) == at(40)
assert next_change(at(40), events, s) == at(110)
assert next_change(at(200), events, s) is None
def answered(title, start_min, length_min, response, calendar=WORK):
start = T0 + timedelta(minutes=start_min)
end = start + timedelta(minutes=length_min)
return Event(title, title, start, end, calendar, response)
def test_accepted_beats_tentative_whichever_is_newer():
# Tentative, newer: the accepted meeting keeps the bar.
events = [answered("Accepted", 0, 60, "accepted"), answered("Maybe", 30, 30, "tentative")]
assert choose(at(26), events, settings()).event.title == "Accepted" # no reminder for Maybe
assert choose(at(40), events, settings()).event.title == "Accepted"
# Accepted, newer: it wins, as it would anyway.
events = [answered("Maybe", 0, 60, "tentative"), answered("Accepted", 30, 30, "accepted")]
assert (
choose(at(26), events, settings()).kind,
choose(at(26), events, settings()).event.title,
) == (
"reminder",
"Accepted",
)
assert choose(at(40), events, settings()).event.title == "Accepted"
assert choose(at(61), events, settings()) is None
def test_same_start_accepted_wins_over_calendar_order():
events = [
answered("Focus time", 0, 115, "tentative", calendar=HOME),
answered("Sync with assistant", 0, 25, "accepted", calendar=WORK),
]
assert choose(at(1), events, settings()).event.title == "Sync with assistant"
# Once it ends, the tentative one is all that is left.
assert choose(at(26), events, settings()).event.title == "Focus time"
def shown(minute, events):
screen = choose(at(minute), events, settings())
return None if screen is None else (screen.kind, screen.event.title)
def test_accepted_wins_over_tentative_at_the_same_time():
# Same start and end; the tentative one is on the earlier calendar and listed first.
events = [
answered("Maybe", 0, 60, "tentative", calendar=HOME),
answered("Sure", 0, 60, "accepted", calendar=WORK),
]
assert shown(30, events) == ("status", "Sure")
assert shown(30, events[::-1]) == ("status", "Sure") # and not by list order
def test_accepted_wins_when_it_starts_after_the_tentative_one():
# Maybe 0-60, Sure 20-80: the accepted reminder (15) and status (20) take the
# bar, and the tentative event does not come back while Sure is still on.
events = [answered("Maybe", 0, 60, "tentative"), answered("Sure", 20, 60, "accepted")]
assert shown(10, events) == ("status", "Maybe")
assert shown(16, events) == ("reminder", "Sure")
assert shown(30, events) == ("status", "Sure")
assert shown(65, events) == ("status", "Sure") # Maybe is over by now
assert shown(81, events) is None
def test_accepted_wins_when_it_starts_before_the_tentative_one():
# Sure 0-40, Maybe 20-80: Maybe's status does not cut in at 20, and takes the
# bar when Sure ends.
events = [answered("Sure", 0, 40, "accepted"), answered("Maybe", 20, 60, "tentative")]
assert shown(10, events) == ("status", "Sure")
assert shown(25, events) == ("status", "Sure")
assert shown(39.9, events) == ("status", "Sure")
assert shown(40, events) == ("status", "Maybe")
assert shown(81, events) is None
def test_accepted_inside_a_longer_tentative_event():
# Maybe 0-120 with Sure 30-60 in the middle: Sure for its slot, Maybe around it.
events = [answered("Maybe", 0, 120, "tentative"), answered("Sure", 30, 30, "accepted")]
assert [shown(m, events) for m in (10, 26, 45, 60, 90)] == [
("status", "Maybe"),
("reminder", "Sure"),
("status", "Sure"),
("status", "Maybe"),
("status", "Maybe"),
]
def test_a_tentative_reminder_does_not_interrupt_an_accepted_event():
# Maybe starts at 40 and would remind from 35, while Sure runs until 60.
events = [answered("Sure", 0, 60, "accepted"), answered("Maybe", 40, 30, "tentative")]
assert shown(36, events) == ("status", "Sure")
assert shown(45, events) == ("status", "Sure")
assert shown(61, events) == ("status", "Maybe")
def test_a_chain_of_shifted_events_always_shows_the_accepted_one():
events = [
answered("Maybe 1", 0, 50, "tentative"),
answered("Sure", 20, 50, "accepted"),
answered("Maybe 2", 40, 50, "tentative"),
]
assert [shown(m, events) for m in (10, 30, 55, 69, 71, 85)] == [
("status", "Maybe 1"),
("status", "Sure"),
("status", "Sure"),
("status", "Sure"),
("status", "Maybe 2"),
("status", "Maybe 2"),
]
def test_an_accepted_event_without_a_status_leaves_the_bar_to_the_tentative_one():
# The settings decide what an event shows; an accepted event that puts nothing
# up (a rule turned its status and reminder off) cannot take the bar from anyone.
events = [answered("Quiet", 0, 60, "accepted"), answered("Maybe", 0, 60, "tentative")]
def for_event(event):
if event.title == "Quiet":
return ReminderSettings(enabled=False), StatusSettings(enabled=False)
return ReminderSettings(), StatusSettings()
assert choose(at(30), events, for_event).event.title == "Maybe"
def test_screen_key_follows_the_response():
# The look depends on it, so an answer changed from the phone redraws the screen.
s = settings()
accepted = [answered("Sync", 0, 60, "accepted")]
maybe = [answered("Sync", 0, 60, "tentative")]
assert choose(at(5), accepted, s).key != choose(at(5), maybe, s).key
assert choose(at(5), maybe, s).key == choose(at(6), maybe, s).key
def test_tentative_beats_unanswered_and_ties_fall_back_to_newest():
events = [answered("No reply", 0, 60, "needs-action"), answered("Maybe", 10, 30, "tentative")]
assert choose(at(20), events, settings()).event.title == "Maybe"
events = [answered("Maybe A", 0, 60, "tentative"), answered("Maybe B", 30, 30, "tentative")]
assert choose(at(40), events, settings()).event.title == "Maybe B"
def test_tentative_events_can_stay_off_the_busy_status():
events = [answered("Maybe", 10, 30, "tentative"), answered("Sure", 60, 30, "accepted")]
quiet = settings(status=StatusSettings(tentative=False))
# The reminder still comes, the status does not; accepted events are unaffected.
assert choose(at(6), events, quiet).kind == "reminder"
assert choose(at(20), events, quiet) is None
assert choose(at(70), events, quiet).kind == "status"
# By default a tentative event shows its status like any other.
assert choose(at(20), events, settings()).kind == "status"
def test_upcoming_finds_the_next_screen_change():
events = [event("Sync", 10), event("Later", 120)]
s = settings(ReminderSettings(minutes_before=5))
assert upcoming(at(0), events, s)[0] == at(5) # Sync's reminder opens
assert upcoming(at(0), events, s)[1].kind == "reminder"
moment, screen = upcoming(at(6), events, s)
assert (moment, screen.kind, screen.event.title) == (at(10), "status", "Sync")
moment, screen = upcoming(at(20), events, s) # Sync ends at 40: Later's reminder at 115
assert (moment, screen.event.title) == (at(115), "Later")
assert upcoming(at(200), events, s) is Nonetests/test_service.py
import plistlib
from pathlib import Path
import pytest
from busybar_calendar import service
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setattr(service, "_home", lambda: tmp_path)
return tmp_path
@pytest.fixture
def ran(monkeypatch):
calls = []
def fake_run(command, **kwargs):
calls.append((list(command), kwargs.get("check", False)))
gone = command[:2] == ["launchctl", "print"] # 113: no such service
return service.subprocess.CompletedProcess(command, 113 if gone else 0, "", "")
monkeypatch.setattr(service.subprocess, "run", fake_run)
return calls
def test_launchd_agent(home):
plan = service.install_plan(Path("cfg/config.toml"), "darwin")
assert plan.path == home / "Library/LaunchAgents/app.busybar.calendar.plist"
agent = plistlib.loads(plan.content)
assert agent["ProgramArguments"][1:] == ["-m", "busybar_calendar", "run"]
# An absolute config path, so the agent does not depend on a working directory.
assert Path(agent["EnvironmentVariables"]["BUSYBAR_CALENDAR_CONFIG"]).is_absolute()
assert agent["RunAtLoad"] and agent["KeepAlive"]
assert agent["ThrottleInterval"] == service.RESTART_AFTER
assert plan.commands[0][:2] == ["launchctl", "bootstrap"]
def test_systemd_unit(home):
config = (home / "cal.toml").resolve()
plan = service.install_plan(config, "linux")
assert plan.path == home / ".config/systemd/user/busybar-calendar.service"
unit = plan.content.decode()
assert f"Environment=BUSYBAR_CALENDAR_CONFIG={config}" in unit
assert "-m busybar_calendar run" in unit
assert f"RestartSec={service.RESTART_AFTER}" in unit
assert plan.commands[-1] == ["systemctl", "--user", "enable", "--now", service.UNIT]
def test_other_platforms_are_refused():
with pytest.raises(OSError, match="macOS and Linux"):
service.install_plan(Path("c.toml"), "win32")
def test_install_writes_then_loads(home, ran):
plan = service.install_plan(Path("c.toml"), "darwin")
service.install(plan)
assert plan.path.read_bytes() == plan.content
assert plan.log.parent.is_dir()
# Unloading an older agent may fail; loading the new one must not.
assert ran == [
(list(plan.cleanup[0]), False),
(list(plan.gone), False), # gone already: no wait
(plan.commands[0], True),
]
def test_a_reinstall_waits_for_the_old_agent_to_be_gone(home, monkeypatch):
calls, naps = [], []
still_there = iter([0, 0, 113]) # launchctl print: loaded, loaded, gone
def fake_run(command, **kwargs):
calls.append(command[1])
code = next(still_there) if command[1] == "print" else 0
return service.subprocess.CompletedProcess(command, code, "", "")
monkeypatch.setattr(service.subprocess, "run", fake_run)
monkeypatch.setattr(service.time, "sleep", naps.append)
service.install(service.install_plan(Path("c.toml"), "darwin"))
# Bootstrapping before the old agent is gone fails with exit 5.
assert calls == ["bootout", "print", "print", "print", "bootstrap"]
assert len(naps) == 2
def test_the_wait_for_the_old_agent_is_bounded(home, monkeypatch):
clock = iter(range(100))
monkeypatch.setattr(service.time, "monotonic", lambda: next(clock))
monkeypatch.setattr(service.time, "sleep", lambda seconds: None)
calls = []
def fake_run(command, **kwargs):
calls.append(command[1])
return service.subprocess.CompletedProcess(command, 0, "", "") # never gone
monkeypatch.setattr(service.subprocess, "run", fake_run)
service.install(service.install_plan(Path("c.toml"), "darwin"))
assert calls[-1] == "bootstrap" # tried anyway after GONE_WITHIN, to say what is wrong
assert calls.count("print") <= service.GONE_WITHIN + 2
def test_uninstall(home, ran):
plan = service.uninstall_plan("linux")
assert service.uninstall(plan) is False # nothing installed
plan.path.parent.mkdir(parents=True)
plan.path.write_text("[Unit]\n")
assert service.uninstall(plan) is True
assert not plan.path.exists()
assert [c for c, _ in ran] == [list(plan.cleanup[0]), plan.commands[0]]
def test_systemd_quotes_paths_with_spaces():
unit = service.systemd_unit(["/opt/my tools/python", "-m", "x"], Path("/a b/c.toml"))
text = unit.decode()
assert 'Environment="BUSYBAR_CALENDAR_CONFIG=/a b/c.toml"' in text
assert 'ExecStart="/opt/my tools/python" -m x' in text
def test_installed_python_reads_the_service_file(home):
for platform in ("darwin", "linux"):
assert service.installed_python(platform) is None # not installed
plan = service.install_plan(Path("c.toml"), platform)
plan.path.parent.mkdir(parents=True)
command = ["/opt/my tools/bin/python", "-m", "busybar_calendar", "run"]
plan.path.write_bytes(
service.launchd_plist(command, Path("/c.toml"), Path("/l.log"))
if platform == "darwin"
else service.systemd_unit(command, Path("/c.toml"))
)
assert service.installed_python(platform) == "/opt/my tools/bin/python"
plan.path.write_text("garbage")
assert service.installed_python(platform) is None
# Shapes taken from `launchctl print` on macOS: the job's fields one tab deep,
# nested blocks (which repeat `state`) deeper.
RUNNING = """gui/501/app.busybar.calendar = {
\tactive count = 1
\tpath = /Users/me/Library/LaunchAgents/app.busybar.calendar.plist
\tstate = running
\tprogram = /usr/bin/python3
\truns = 1
\tpid = 20464
\tlast exit code = (never exited)
\tendpoints = {
\t\tstate = active
\t}
}
"""
STOPPED = (
RUNNING.replace("state = running", "state = not running")
.replace("\tpid = 20464\n", "")
.replace("(never exited)", "1")
)
def test_parse_launchctl_takes_only_the_jobs_own_fields():
fields = service.parse_launchctl(RUNNING)
assert (fields["state"], fields["pid"]) == ("running", "20464")
class Done:
def __init__(self, returncode=0, stdout=""):
self.returncode, self.stdout = returncode, stdout
@pytest.mark.parametrize(
("output", "expected"),
[
(Done(0, RUNNING), (True, 20464, "")),
(Done(0, STOPPED), (False, None, "last exit code 1")),
(Done(113, ""), (False, None, "not loaded")),
],
)
def test_status_on_macos(home, monkeypatch, output, expected):
agent = home / "Library/LaunchAgents/app.busybar.calendar.plist"
agent.parent.mkdir(parents=True)
agent.write_text("<plist/>")
monkeypatch.setattr(service.subprocess, "run", lambda *a, **k: output)
state = service.status("darwin")
assert state.installed
assert (state.running, state.pid, state.detail) == expected
assert state.log == home / "Library/Logs/busybar-calendar.log"
def test_status_not_installed(home, monkeypatch):
monkeypatch.setattr(service.subprocess, "run", lambda *a, **k: Done(113))
state = service.status("darwin")
assert (state.installed, state.running, state.detail) == (False, False, "")
def test_status_on_linux(home, monkeypatch):
monkeypatch.setattr(
service.subprocess,
"run",
lambda *a, **k: Done(0, "ActiveState=failed\nMainPID=0\nExecMainStatus=1\n"),
)
state = service.status("linux")
assert (state.running, state.pid, state.detail) == (False, None, "last exit code 1")
monkeypatch.setattr(
service.subprocess,
"run",
lambda *a, **k: Done(0, "ActiveState=active\nMainPID=4321\nExecMainStatus=0\n"),
)
assert service.status("linux").pid == 4321
def test_recent_log_reads_the_tail(tmp_path):
log = tmp_path / "busybar-calendar.log"
log.write_text("\n".join(f"line {n}" for n in range(20)))
state = service.Status(True, True, log=log)
assert service.recent_log(state, 3) == ["line 17", "line 18", "line 19"]
assert service.recent_log(service.Status(True, True, log=tmp_path / "none"), 3) == []
def test_restart_only_an_installed_service(home, ran):
assert service.restart("darwin") is False
assert ran == []
agent = home / "Library/LaunchAgents/app.busybar.calendar.plist"
agent.parent.mkdir(parents=True)
agent.write_text("<plist/>")
assert service.restart("darwin") is True
# Unloaded and loaded again: `kickstart -k` waits out the 5-minute throttle.
assert [command[:2] for command, _ in ran] == [
["launchctl", "bootout"],
["launchctl", "print"], # gone: load it
["launchctl", "bootstrap"],
]
assert ran[-1][0][-1] == str(agent)
@pytest.fixture
def uv_from_git(monkeypatch):
monkeypatch.setattr(service.shutil, "which", lambda name: "/opt/uv/bin/uv")
monkeypatch.setattr(service.update, "from_git", lambda python: True)
def test_a_self_updating_launchd_agent(home, uv_from_git):
plan = service.install_plan(Path("c.toml"), "darwin", auto_update=True)
agent = plistlib.loads(plan.content)
assert agent["ProgramArguments"][1:] == ["-m", "busybar_calendar", "run", "--auto-update"]
env = agent["EnvironmentVariables"]
# launchd's own PATH has neither uv nor its folder; git and `security` are in /usr/bin.
assert env["PATH"].split(":")[:2] == ["/opt/uv/bin", "/usr/local/bin"]
assert "/usr/bin" in env["PATH"].split(":")
assert Path(env["BUSYBAR_CALENDAR_CONFIG"]).is_absolute()
assert agent["RunAtLoad"] and agent["KeepAlive"]
assert not service.auto_updates("darwin") # not installed yet
plan.path.parent.mkdir(parents=True)
plan.path.write_bytes(plan.content)
assert service.auto_updates("darwin")
def test_a_self_updating_systemd_unit(home, uv_from_git):
plan = service.install_plan(Path("c.toml"), "linux", auto_update=True)
unit = plan.content.decode()
assert "-m busybar_calendar run --auto-update" in unit
assert "Environment=PATH=/opt/uv/bin:/usr/local/bin:/usr/bin:" in unit
plan.path.parent.mkdir(parents=True)
plan.path.write_bytes(plan.content)
assert service.auto_updates("linux")
def test_auto_update_needs_uv_and_an_install_from_git(home, monkeypatch):
monkeypatch.setattr(service.shutil, "which", lambda name: None)
with pytest.raises(OSError, match="no uv on PATH"):
service.install_plan(Path("c.toml"), "darwin", auto_update=True)
monkeypatch.setattr(service.shutil, "which", lambda name: "/opt/uv/bin/uv")
monkeypatch.setattr(service.update, "from_git", lambda python: False)
with pytest.raises(OSError, match="installed from git"):
service.install_plan(Path("c.toml"), "darwin", auto_update=True)
# Without it, neither is needed, and the agent is as before.
agent = plistlib.loads(service.install_plan(Path("c.toml"), "darwin").content)
assert "PATH" not in agent["EnvironmentVariables"]
assert agent["ProgramArguments"][-1] == "run"tests/test_sources.py
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from busybar_calendar.caldav import RemoteCalendar
from busybar_calendar.config import Config
from busybar_calendar.sources import CalDAVSource, select
BASE = "https://dav.example.com/dav.php/calendars/me@example.com"
MINE = RemoteCalendar(f"{BASE}/4711/", "Doe, Jane")
OPS = RemoteCalendar(f"{BASE}/5001/", "Ops Maintenances")
SRE = RemoteCalendar(f"{BASE}/5002/", "Infra Maintenances")
ROOM_A = RemoteCalendar(f"{BASE}/6001/", "Office - Room 1 (6 people)")
ROOM_B = RemoteCalendar(f"{BASE}/6002/", "Office - Room 2 (8 people)")
ALL = [MINE, OPS, SRE, ROOM_A, ROOM_B]
def names(calendars):
return [c.name for c in calendars]
def test_calendar_id_is_the_last_url_segment():
assert MINE.id == "4711"
assert RemoteCalendar("https://x/cal/My%20Cal", "My Cal").id == "My Cal"
def test_no_filter_means_all_in_server_order():
assert select(ALL, [], []) == (ALL, [])
def test_include_by_name_id_url_and_wildcard():
assert select(ALL, ["Doe, Jane"], []) == ([MINE], [])
assert select(ALL, ["4711"], []) == ([MINE], [])
assert select(ALL, [f"{BASE}/4711/"], []) == ([MINE], [])
assert select(ALL, [f"{BASE}/4711"], []) == ([MINE], []) # trailing slash optional
assert names(select(ALL, ["* Maintenances"], [])[0]) == [
"Ops Maintenances",
"Infra Maintenances",
]
def test_include_order_decides_overlaps_and_matches_once():
chosen, _ = select(ALL, ["500*", "4711", "Infra Maintenances"], [])
# Listed order, each calendar once: the last listed wins an overlap.
assert names(chosen) == ["Ops Maintenances", "Infra Maintenances", "Doe, Jane"]
def test_exclude_by_wildcard_and_id():
assert select(ALL, [], ["Office - Room *"]) == ([MINE, OPS, SRE], [])
assert select(ALL, ["*"], ["Office - Room *", "5001"]) == ([MINE, SRE], [])
def test_entries_that_match_nothing_are_reported():
chosen, unmatched = select(ALL, ["4711", "Typo"], ["Annex - Room *"])
assert chosen == [MINE]
assert unmatched == ["Typo", "Annex - Room *"]
def test_names_are_case_sensitive_and_exact():
assert select(ALL, ["doe, jane"], [])[0] == []
assert select(ALL, ["Doe"], [])[0] == []
def test_overrides_by_id_layer_over_name():
config = Config.model_validate(
{
"sources": [
{
"name": "Mail",
"type": "caldav",
"url": "https://x",
"reminder": {"minutes_before": 3},
"calendars": {
"Doe, Jane": {"reminder": {"minutes_before": 7, "sound": False}},
"4711": {"reminder": {"minutes_before": 10}},
},
}
]
}
)
reminder, _ = config.settings_for("Mail", "Doe, Jane", "4711")
assert (reminder.minutes_before, reminder.sound) == (10, False)
reminder, _ = config.settings_for("Mail", "Other", "99")
assert (reminder.minutes_before, reminder.sound) == (3, True)
class FakeClient:
def __init__(self, calendars, documents):
self.calendars = calendars
self.documents = documents
async def discover(self):
return self.calendars
async def events(self, calendar, start, end):
return self.documents.get(calendar.id, [])
def ics(uid, start):
stamp = start.strftime("%Y%m%dT%H%M%SZ")
end = (start + timedelta(minutes=30)).strftime("%Y%m%dT%H%M%SZ")
return (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"UID:{uid}\r\nSUMMARY:{uid}\r\nDTSTART:{stamp}\r\nDTEND:{end}\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
async def test_source_fetches_only_selected_calendars_with_ids():
config = Config.model_validate(
{
"sources": [
{
"name": "Mail",
"type": "caldav",
"url": "https://x",
"include": ["Infra Maintenances", "4711"],
"exclude": ["Office - Room *"],
}
]
}
)
start = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
source = CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
source._client = FakeClient(
ALL,
{
"4711": [ics("mine", start)],
"5002": [ics("sre", start)],
"6001": [ics("room", start)],
},
)
events = await source.fetch(start - timedelta(hours=1), start + timedelta(hours=1))
by_title = {e.title: e.calendar for e in events}
assert set(by_title) == {"mine", "sre"}
assert (by_title["mine"].id, by_title["mine"].name) == ("4711", "Doe, Jane")
# Listed last, so it outranks Infra Maintenances when both start together.
assert by_title["mine"].order > by_title["sre"].order
await source.aclose()
class FailingClient(FakeClient):
def __init__(self, error):
super().__init__([], {})
self.error = error
async def discover(self):
raise self.error
async def test_wrong_url_or_password_is_fatal_but_a_server_error_is_not():
import pytest
from busybar_calendar.caldav import CalDAVAuthError, CalDAVError
from busybar_calendar.sources import FatalSourceError
config = Config.model_validate(
{"sources": [{"name": "M", "type": "caldav", "url": "https://x"}]}
)
start = datetime(2026, 10, 13, tzinfo=UTC)
for error, fatal in (
(CalDAVAuthError("401", 401), True),
(CalDAVError("404", 404), True),
(CalDAVError("no current-user-principal"), True),
(CalDAVError("503", 503), False),
):
source = CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
source._client = FailingClient(error)
with pytest.raises(FatalSourceError if fatal else CalDAVError):
await source.fetch(start, start + timedelta(hours=1))
await source.aclose()
async def test_a_failed_relisting_keeps_the_known_calendars(caplog):
import httpx
from busybar_calendar.caldav import CalDAVAuthError, CalDAVError
from busybar_calendar.sources import REDISCOVER_EVERY, FatalSourceError
config = Config.model_validate(
{"sources": [{"name": "M", "type": "caldav", "url": "https://x", "include": ["4711"]}]}
)
start = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
window = (start - timedelta(hours=1), start + timedelta(hours=1))
source = CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
client = FakeClient(ALL, {"4711": [ics("mine", start)]})
source._client = client
listed = []
async def discover():
listed.append(1)
return ALL
client.discover = discover
for _ in range(REDISCOVER_EVERY):
await source.fetch(*window)
assert len(listed) == 1 # listed once, then again only every REDISCOVER_EVERY
for hiccup in (CalDAVError("503", 503), httpx.ConnectTimeout("timed out")):
async def failing(error=hiccup):
raise error
client.discover = failing
events = await source.fetch(*window) # due to list again; the server hiccups
assert [e.title for e in events] == ["mine"]
assert "going on with the ones it knows" in caplog.text
client.discover = discover
await source.fetch(*window) # listed again at the very next refresh
assert len(listed) == 2
async def refused():
raise CalDAVAuthError("401", 401)
client.discover = refused
for _ in range(REDISCOVER_EVERY):
try:
await source.fetch(*window)
except FatalSourceError:
break # a password that stopped working still stops the service
else:
raise AssertionError("a 401 while listing again must be fatal")
await source.aclose()tests/test_update.py
import os
import subprocess
import sys
import pytest
from busybar_calendar import __version__, update
@pytest.fixture
def git_tool(monkeypatch):
monkeypatch.setattr(update, "from_git", lambda python: True)
monkeypatch.setattr(update.shutil, "which", lambda name: "/opt/uv/bin/uv")
def on_disk(monkeypatch, *versions):
seen = iter(versions)
monkeypatch.setattr(update, "installed", lambda: next(seen))
def answers(code=0, stderr=""):
return lambda command, **kwargs: subprocess.CompletedProcess(command, code, "", stderr)
def test_an_update_that_brings_new_code(git_tool, monkeypatch, caplog):
calls = []
def run(command, **kwargs):
calls.append((command, kwargs["timeout"]))
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr(update.subprocess, "run", run)
on_disk(monkeypatch, "0.3.1 (2713373)", "0.6.1 (0046d3c)")
with caplog.at_level("INFO"):
assert update.update() is True
assert calls == [(["/opt/uv/bin/uv", "tool", "upgrade", "busybar-calendar"], update.TIMEOUT)]
assert "0.3.1 (2713373) -> 0.6.1 (0046d3c)" in caplog.text
def test_new_commits_count_even_without_a_new_version(git_tool, monkeypatch):
monkeypatch.setattr(update.subprocess, "run", answers())
on_disk(monkeypatch, "0.6.1 (0046d3c)", "0.6.1 (9f8e7d6)")
assert update.update() is True
def test_already_the_latest(git_tool, monkeypatch, caplog):
monkeypatch.setattr(update.subprocess, "run", answers())
on_disk(monkeypatch, "0.6.1 (0046d3c)", "0.6.1 (0046d3c)")
with caplog.at_level("INFO"):
assert update.update() is False
assert "0.6.1 (0046d3c) is the latest" in caplog.text
def test_a_failed_update_starts_what_is_there(git_tool, monkeypatch, caplog):
def timeout(command, **kwargs):
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
def missing(command, **kwargs):
raise FileNotFoundError(command[0])
failing = (answers(2, "error: Failed to fetch\n Caused by: dns error\n"), timeout, missing)
for run in failing: # no network yet at login, a hung fetch, uv gone
monkeypatch.setattr(update.subprocess, "run", run)
on_disk(monkeypatch, "0.6.1 (0046d3c)")
assert update.update() is False
assert caplog.text.count("staying on 0.6.1 (0046d3c)") == 3
assert "Caused by: dns error" in caplog.text # uv's last line says why
def test_only_a_uv_tool_from_git_updates_itself(monkeypatch, caplog):
def never(command, **kwargs):
raise AssertionError("must not run uv")
monkeypatch.setattr(update.subprocess, "run", never)
monkeypatch.setattr(update, "from_git", lambda python: False)
assert update.update() is False
assert "only a uv tool installed from git" in caplog.text
monkeypatch.setattr(update, "from_git", lambda python: True)
monkeypatch.setattr(update.shutil, "which", lambda name: None)
assert update.update() is False
assert "no uv on PATH" in caplog.text
def test_installed_reads_the_version_on_disk():
assert update.installed().startswith(__version__)
def test_restart_into_update_reruns_the_same_command(monkeypatch):
seen = []
monkeypatch.setattr(update.os, "execv", lambda path, argv: seen.append((path, argv)))
monkeypatch.setenv(update.UPDATED_ENV, "")
argv = ["/x/bin/python", "-m", "busybar_calendar", "run", "--auto-update"]
monkeypatch.setattr(update.sys, "orig_argv", argv)
update.restart_into_update()
assert seen == [(sys.executable, [sys.executable, *argv[1:]])]
assert os.environ[update.UPDATED_ENV] == "1" # the new process does not update againtests/test_wizard.py
import stat
from busybar_calendar import wizard
from busybar_calendar.caldav import CalDAVAuthError, RemoteCalendar
from busybar_calendar.config import load
FOUND = [
RemoteCalendar("https://dav.example.com/cal/me/1001/", "Home"),
RemoteCalendar("https://dav.example.com/cal/me/1002/", "Work"),
RemoteCalendar("https://dav.example.com/cal/me/2001/", "Room 1 (6 people)"),
]
def script(*answers):
pending = list(answers)
asked = []
def ask(prompt):
asked.append(prompt)
return pending.pop(0)
ask.asked = asked
return ask
class FakeStore:
def __init__(self, keychain=True):
self.saved = []
self.keychain = keychain
def save(self, service, account, env):
self.saved.append((service, account))
if self.keychain:
return wizard.Secret(command=["security", "find-generic-password", "-s", service, "-w"])
return wizard.Secret(env=env)
def test_cloud_and_an_icloud_account(tmp_path):
path = tmp_path / "busybar-calendar" / "config.toml"
# cloud (default), CalDAV (default), username, the guessed provider, two calendars
ask = script("", "", "me@icloud.com", "", "2, 1")
store = FakeStore()
assert wizard.run(path, ask=ask, store=store, discover=lambda source: FOUND) == 0
assert ask.asked[3] == "Choose 1-5 [1]: " # iCloud, guessed from the address
assert stat.S_IMODE(path.stat().st_mode) == 0o600
config = load(path)
assert config.bar.address is None
assert config.bar.token.command[-2:] == ["busybar-calendar-bar", "-w"]
[source] = config.sources
assert (source.name, source.url, source.username) == (
"iCloud",
"https://caldav.icloud.com",
"me@icloud.com",
)
assert source.include == ["Home", "Work"] # in the server's order
assert store.saved == [
("busybar-calendar-bar", "busybar-calendar"),
("busybar-calendar-me@icloud.com", "me@icloud.com"), # one entry per account
]
def test_usb_and_an_ics_link(tmp_path):
path = tmp_path / "config.toml"
ask = script("3", "2", "webcal://example.com/team.ics")
assert wizard.run(path, ask=ask, store=FakeStore(), discover=None) == 0
config = load(path)
assert config.bar.address == "10.0.4.20"
assert config.bar.token.resolve() is None
assert (config.sources[0].type, config.sources[0].url) == (
"ics",
"https://example.com/team.ics",
)
def test_wifi_without_a_keychain_and_a_failed_sign_in(tmp_path, capsys):
path = tmp_path / "config.toml"
def refuse(source):
raise CalDAVAuthError("PROPFIND https://dav.example.com: 401, check the credentials", 401)
ask = script("2", "192.168.1.50", "1", "me@example.com", "", "https://dav.example.com")
assert wizard.run(path, ask=ask, store=FakeStore(keychain=False), discover=refuse) == 0
out = capsys.readouterr().out
assert "Could not sign in" in out
text = path.read_text()
assert 'token_env = "BUSYBAR_TOKEN"' in text
assert 'password_env = "CALDAV_PASSWORD"' in text
assert "include" not in text
assert load(path).sources[0].name == "example.com"
def test_all_calendars_when_none_are_picked(tmp_path):
path = tmp_path / "config.toml"
ask = script("3", "1", "me@example.com", "", "https://dav.example.com", "")
wizard.run(path, ask=ask, store=FakeStore(), discover=lambda source: FOUND)
assert load(path).sources[0].include == []
def test_bad_answers_are_asked_again(tmp_path):
path = tmp_path / "config.toml"
ask = script("9", "x", "3", "2", "https://example.com/a.ics")
assert wizard.run(path, ask=ask, store=FakeStore(), discover=None) == 0
assert len(ask.asked) == 5
def test_an_existing_config_is_kept_unless_confirmed(tmp_path):
path = tmp_path / "config.toml"
path.write_text("keep me")
assert wizard.run(path, ask=script("n"), store=FakeStore(), discover=None) == 1
assert path.read_text() == "keep me"
ask = script("y", "3", "2", "https://example.com/a.ics")
assert wizard.run(path, ask=ask, store=FakeStore(), discover=None) == 0
assert "keep me" not in path.read_text()
EXISTING = """# my config, with my own comments
[bar]
address = "10.0.4.20"
[[sources]]
name = "iCloud"
type = "caldav"
url = "https://caldav.icloud.com"
username = "me@icloud.com"
password_env = "ICLOUD"
[sources.calendars.Work.status]
show = "meeting"
"""
def test_add_appends_a_source_and_keeps_the_rest(tmp_path):
path = tmp_path / "config.toml"
path.write_text(EXISTING)
# A second iCloud account: the name must not clash with the first.
ask = script("1", "partner@icloud.com", "", "1")
store = FakeStore()
assert wizard.add(path, ask=ask, store=store, discover=lambda source: FOUND) == 0
text = path.read_text()
assert text.startswith(EXISTING.rstrip("\n")) # comments and layout untouched
config = load(path)
assert [s.name for s in config.sources] == ["iCloud", "iCloud 2"]
assert config.sources[1].include == ["Home"]
assert config.settings_for("iCloud", "Work")[1].show == "meeting" # still applies
assert store.saved == [("busybar-calendar-partner@icloud.com", "partner@icloud.com")]
def test_add_an_ics_link(tmp_path):
path = tmp_path / "config.toml"
path.write_text(EXISTING)
assert (
wizard.add(
path, ask=script("2", "webcal://example.com/team.ics"), store=FakeStore(), discover=None
)
== 0
)
assert load(path).sources[1].url == "https://example.com/team.ics"
def test_add_needs_a_config(tmp_path):
import pytest
from busybar_calendar.config import ConfigError
with pytest.raises(ConfigError, match="busybar-calendar init"):
wizard.add(tmp_path / "none.toml", ask=script(), store=FakeStore(), discover=None)
def test_keychain_prompt_is_asked_again_then_gives_up_cleanly(monkeypatch, capsys):
import subprocess
import pytest
from busybar_calendar.config import ConfigError
codes = [1, 0] # a mistyped retype, then right
monkeypatch.setattr(wizard.shutil, "which", lambda name: "/usr/bin/security")
monkeypatch.setattr(
wizard.subprocess,
"run",
lambda command, **kw: subprocess.CompletedProcess(command, codes.pop(0)),
)
secret = wizard.SecretStore("darwin").save("svc", "me", "ENV")
assert secret.command[-2:] == ["svc", "-w"]
assert "Once more" in capsys.readouterr().out
monkeypatch.setattr(
wizard.subprocess, "run", lambda command, **kw: subprocess.CompletedProcess(command, 1)
)
with pytest.raises(ConfigError, match="did not store the password"):
wizard.SecretStore("darwin").save("svc", "me", "ENV")
def caldav_source(tmp_path, *answers):
path = tmp_path / "config.toml"
ask = script("3", "1", *answers) # USB, CalDAV, then the answers given
wizard.run(path, ask=ask, store=FakeStore(), discover=lambda source: FOUND)
return load(path).sources[0], ask
def test_providers_fill_in_the_server(tmp_path):
source, ask = caldav_source(tmp_path, "me@mydomain.com", "3", "") # PrivateEmail
assert (source.name, source.url) == ("PrivateEmail", "https://dav.privateemail.com")
assert ask.asked[3] == "Choose 1-5 [5]: " # an unknown domain: another server by default
def test_nextcloud_needs_only_the_host(tmp_path):
source, _ = caldav_source(tmp_path, "me@example.com", "4", "cloud.example.com/", "")
assert source.url == "https://cloud.example.com/remote.php/dav"
assert source.name == "example.com"
def test_fastmail_is_guessed(tmp_path):
source, ask = caldav_source(tmp_path, "me@fastmail.com", "", "")
assert (source.name, source.url) == ("Fastmail", "https://caldav.fastmail.com")
assert ask.asked[3] == "Choose 1-5 [2]: "Source and Provenance
View This Version on GitHub → · Author’s Project →
Source commit: 2580e9aa0b4c667ae7906474b6a767d72b3f4fe0
Content SHA-256: 62357ac5b2d66ec3fa09fddcafbf583bda25ac22ea2d980b54d19e9cb5d1341d
Rate and Discuss
Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.












