Skip to main content

E•NOTE•ION

E•NOTE•ION

CI

A self-hosted, code-first content scheduler for Vestaboard split-flap displays. Define your board content as version-controlled JSON — cron schedules, templated messages, live data integrations, and a priority queue — with no web UI or cloud dependency required. Supports both the Note (3×15) and the Flagship (6×22).

This project is primarily agent-developed using Claude, with human design, decision-making, guidance, and review. See Philosophy for more on the approach.

Who this is for

E•NOTE•ION is built for developers and power users who want to treat their board like infrastructure: content in files, schedules in cron, secrets in config.toml, deploys in Docker.

If you'd prefer a friendlier experience — a web UI, drag-and-drop scheduling, and a polished setup flow — check out FiestaBoard, which nails that use case beautifully.

See also

The Vestaboard community has built a lot of great tooling:

Project What it does well
FiestaBoard Full-featured self-hosted app with a web UI and a rich scheduling experience
Vestaboard+ Official cloud subscription with Zapier/IFTTT integration and a curated app marketplace
jparise/vesta Clean Python library for the Vestaboard API — great if you want to build your own tooling
natekspencer/hacs-vestaboard Home Assistant integration for triggering board updates from automations
Zapier / IFTTT No-code workflow triggers via Vestaboard+ — lowest barrier to entry
MCP servers Emerging tools for LLM-driven board updates from Claude and other agents

Pre-built multi-arch images (linux/amd64, linux/arm64) are published to the GitHub Container Registry on each release.

First copy config.example.toml to config.toml and fill in your API keys and settings (see Configuration below). Then run:

docker run -d \
  --name e-note-ion \
  --restart unless-stopped \
  -v /path/to/config.toml:/app/config.toml:ro \
  ghcr.io/jasonpuglisi/e-note-ion:latest

To mount personal content, add a volume pointing at /app/content/user:

  -v /path/to/your/content:/app/content/user \

Display model, public mode, and enabled contrib content are all configured in config.toml under [scheduler] — no environment variables needed for these settings. See Configuration for details.

Contrib integrations require their own API keys and configuration — see content/README.md for details.

Unraid

An Unraid Docker template is available in a separate repository. It exposes config file path, user content directory, timezone, and webhook port as UI fields.

Viewing container logs

Some integrations print important messages to stdout during startup or operation — for example, an authentication code and URL you need to visit to complete an OAuth flow. Check the container logs to see these messages.

Docker:

docker logs e-note-ion
# or follow live:
docker logs -f e-note-ion

Unraid: In the Unraid web UI, go to Docker → click the container icon next to e-note-ion → Logs.

Integrations that require interactive auth

Some integrations (e.g. Trakt.tv) use an OAuth device code flow: the scheduler prints a short code and URL to the container logs, you visit the URL on any device and approve access, and tokens are automatically saved to config.toml. No browser on the scheduler host is required.

For this to work, config.toml must be mounted read-write (not :ro) so the scheduler can persist the tokens:

# Correct — read-write (required when using auth-based integrations):
-v /path/to/config.toml:/app/config.toml

# Wrong — read-only prevents token persistence:
-v /path/to/config.toml:/app/config.toml:ro

Until auth is complete, templates from that integration are silently skipped and the display shows other content normally. See each integration's sidecar doc under content/contrib/ for setup details.

Upgrading

Breaking changes and the steps to move between major versions are in MIGRATIONS.md. Check it before upgrading across a major.

Configuration

Copy config.example.toml to config.toml and fill in your values:

cp config.example.toml config.toml
# edit config.toml — add your Vestaboard API key and any integration settings

config.toml is git-ignored and contains secrets — never commit it.

Key [scheduler] settings:

Key Default Description
model "note" Display model: "note" (3×15) or "flagship" (6×22)
public false When true, skip templates marked private = true (for shared/guest-visible spaces). Can be toggled at runtime via POST /webhook/scheduler with {"action": "public"} or {"action": "private"}
content_enabled (absent) Content filter for cron-scheduled templates in both user/ and contrib/: absent = all user loads, no contrib; ["*"] = all user + all contrib; ["bart", "my_quotes"] = only matching stems from either directory. Webhook-only integrations (plex, message, notion) are unaffected — their webhooks fire regardless of this setting.
timezone system TZ IANA timezone for cron job scheduling (e.g. "America/Los_Angeles")
log_level "INFO" Logging level: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL". "DEBUG" shows per-integration cache hits, retry attempts, and state transitions
quiet false When true, render content to virtual state instead of sending it to the board. Normally written by POST /webhook/scheduler with {"action": "quiet"} / {"action": "wake"} (optionally "delay": <seconds>; see scheduler.md), but can be set manually before startup
min_hold 60 Minimum seconds any message stays on display before a high-priority (≥8) queued message can interrupt it. Set to 0 to disable (not recommended for physical displays).

Calendar-driven gating

Optionally let calendar events open or close template gates at runtime. When the gate is closed, the cron continues to fire but the enqueue is silently dropped until a covering event reopens it. The motivating use case: "show BART only on days I commute" — list bart.departures in gated_templates, then mark commute days on a calendar event with vestaboard:bart.departures in the description.

Reuses the calendars already configured under [calendar] (both ICS and CalDAV). In any event description, write vestaboard:<token> keywords (one per line, case-insensitive). The token is a file stem (bart) or fully-qualified template id (bart.departures); prefix with ! to deny instead of allow. Deny wins on conflict. Webhook and refresh enqueues are not gated — only cron firings are.

[scheduler.calendar_schedule]
gated_templates = ["bart.departures"]   # closed by default; calendar opens

See config.example.toml for the full keyword grammar and edge cases.

Health monitoring

Credentials are validated once at startup. An integration whose key the service rejects (HTTP 401/403) is marked unhealthy immediately and shows on /health, rather than staying silent until its cron next fires — up to a day later for the daily ones. Startup continues either way: one expired token does not take the board down.

When the webhook listener is enabled, a health endpoint is available at GET /health. It returns a JSON summary of all registered integration statuses, useful for uptime monitoring (e.g. UptimeRobot).

Authentication: A credential is auto-generated on first startup — check the container logs for the plaintext secret. Pass it as X-Webhook-Secret: <secret> (preferred) or ?secret=<secret>.

HTTP status codes:

  • 200 — everything healthy (or unknown)
  • 503 — one or more targets degraded or errored

Response format:

{
  "status": "healthy",
  "uptime_seconds": 3600,
  "vestaboard": {
    "status": "healthy",
    "last_success": "2026-01-01T12:00:00+00:00",
    "last_expected_empty": null,
    "last_error": null,
    "last_error_message": null,
    "last_locked": "2026-01-01T07:00:00+00:00",
    "locked_events": 2,
    "success_rate": 1.0,
    "total_events": 10,
    "registered_at": "2026-01-01T08:00:00+00:00"
  },
  "integrations": {
    "weather": {
      "status": "healthy",
      "last_success": "2026-01-01T12:00:00+00:00",
      "last_expected_empty": null,
      "last_error": null,
      "last_error_message": null,
      "last_locked": null,
      "locked_events": 0,
      "success_rate": 1.0,
      "total_events": 10,
      "registered_at": "2026-01-01T08:00:00+00:00"
    }
  }
}

The vestaboard key tracks the display send path itself (POST to the Read/Write API) separately from integrations, so a Vestaboard outage does not smear across every integration's status — and vice versa. Each target tracks the last 20 events in a rolling buffer. Status levels: healthy (≥70% non-error rate), degraded (below threshold), error (all errors), unknown (no events yet). Expected empty data (e.g. nothing playing, no events today) counts as healthy — only API failures trigger degraded/error. Vestaboard locked responses (HTTP 423 during quiet hours) are tracked separately via last_locked / locked_events and do not affect status.

Health events are persisted to data/health.jsonl so that history survives container restarts. Events older than 7 days are automatically purged. In Docker, the data/ directory is an anonymous volume — no user configuration is needed for persistence across stop/start cycles.

A periodic health summary also logs to the console every hour, showing non-healthy integrations and their recent error rates.

Board state endpoint

When the webhook listener is enabled, GET /state returns the current board content plus the runtime mode toggles:

{
  "modes": { "quiet": false, "public": false },
  "source": "board",
  "grid": [[8, 0, 0]],
  "rendered": "HELLO",
  "timestamp": "2026-01-01T12:00:00-08:00"
}

modes is always present; grid/rendered/timestamp are null when no content is known. source is board (last grid sent), virtual (quiet-mode buffer), or empty. Add ?refresh=true to force an authoritative fetch from the Vestaboard API. Authentication uses the auto-generated state credential (X-Webhook-Secret header or ?secret=), like /health.

Apple Home (HomeBridge)

Quiet and Public modes can be exposed as native Apple Home switches via HomeBridge — manual toggles, Siri, and Home automations. The GET /state endpoint above keeps the switches in sync, and an optional [homebridge] config section pushes mode changes to HomeBridge instantly. See docs/homebridge.md for the full setup.

Installing from PyPI

Requirements: Python 3.14+

pip install e-note-ion

Create a config file and run:

cp config.example.toml config.toml  # fill in your API key
e-note-ion                           # or: e-note-ion --config /path/to/config.toml

Use e-note-ion --help for CLI options.

Running from source

Requirements: Python 3.14+, uv

uv sync
cp config.example.toml config.toml  # fill in your API key
uv run e-note-ion

Display model, public mode, and content filter are set in config.toml under [scheduler]. See Configuration for details.

Content files

Content is defined as JSON files in two directories:

  • content/contrib/ — bundled community-contributed content, disabled by default. Enable via [scheduler].content_enabled in config.toml.
  • content/user/ — personal content. Loaded automatically when content_enabled is absent; filtered alongside contrib when it is set. Git-ignored; mount your own directory here or symlink to a private repo.

See content/README.md for the full content format reference, including template fields, variables, color squares, priority guidelines, schedule overrides, and available integrations.

Philosophy

Content as code. Board messages live in JSON files alongside your other dotfiles and configs. They're version-controlled, diff-able, and deployable the same way as everything else. There's no database to back up, no UI state to sync, and no vendor lock-in — just files, cron, and a single Python process.

An AI development experiment. E•NOTE•ION is also an ongoing exploration of agentic software development. Most of the implementation is written by Claude, with a human setting direction, reviewing plans, and making architectural calls. The goal isn't to remove the human — it's to see how far thoughtful human–AI collaboration can go on a real project with real constraints.

Development

uv sync
uv run pre-commit install

Run the full check suite before committing:

uv run ruff check .
uv run ruff format --check .
uv run pyright
uv run bandit -c pyproject.toml -r .
uv run pip-audit
uv run pre-commit run pretty-format-json --all-files
uv run pytest --cov --cov-report=term-missing

Every check except pytest also runs as a pre-commit hook.

Integration tests

Integration tests hit the real APIs and are excluded from the default pytest run. To run them locally:

cp .env.example .env
# fill in your API keys — bare values, no surrounding quotes
uv run pytest -m integration -v

Required keys:

Key Where to get it
VESTABOARD_VIRTUAL_API_KEY web.vestaboard.com → Developer → Virtual Boards
CALENDAR_URL Google/iCloud: secret-address .ics URL (see content/contrib/calendar.md)
CALENDAR_CALDAV_URL https://caldav.icloud.com/ for iCloud CalDAV
CALENDAR_USERNAME Apple ID email address
CALENDAR_PASSWORD App-specific password from appleid.apple.com
BART_API_KEY api.bart.gov/api/register.aspx
DISCOGS_TOKEN discogs.com/settings/developers
TRAKT_CLIENT_ID trakt.tv/oauth/applications → your app
TRAKT_CLIENT_SECRET same app page
TRAKT_ACCESS_TOKEN run Trakt auth flow once and copy from config.toml
TMDB_API_READ_ACCESS_TOKEN themoviedb.org/settings/api (optional; enhances Plex/Trakt metadata)
CALENDAR_CARDDAV_URL https://contacts.icloud.com/ for iCloud birthday integration
PARCEL_API_KEY web.parcelapp.net → API key (requires Parcel Premium)
DIVING_NDBC_STATION ndbc.noaa.gov station ID (e.g. 46221)
DIVING_LAT Station latitude
DIVING_LON Station longitude
YNAB_API_KEY app.ynab.com/settings/developer → Personal Access Token
YNAB_BUDGET_ID Budget UUID from the YNAB web app URL
UPTIMEROBOT_API_KEY uptimerobot.com/dashboard.php#mySettings → API Settings → Main API Key
GOOGLE_CLIENT_ID console.cloud.google.com/apis/credentials → OAuth 2.0 Client ID (TV/limited input device)
GOOGLE_CLIENT_SECRET Same OAuth client as above
GOOGLE_REFRESH_TOKEN Run YouTube auth flow once and copy from config.toml (Production OAuth mode → no expiry; Testing → 7-day)

.env is git-ignored — never commit it.

Metadata

Release files for e-note-ion 2.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for e-note-ion 2.2.0
File Size Uploaded
e_note_ion-2.2.0.tar.gz 136.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for e-note-ion 2.2.0
File Interpreter ABI Platform
e_note_ion-2.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 285.2 kB

Release files / e_note_ion-2.2.0.tar.gz

Download URL e_note_ion-2.2.0.tar.gz
Size 136.3 kB
Tags Source
SHA-256 checksum
How to use checksums
cd8a2f108f8a408ebbb1ad0acfb6582a8259302b8d9a28b7159d1e1a159c1ecc
BLAKE2b-256 checksum
How to use checksums
e3fdbc09eca2d9e5e5c8e7e747abe8335462309a8e023ef6a3a2c020fdcad749
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release files / e_note_ion-2.2.0-py3-none-any.whl

Download URL e_note_ion-2.2.0-py3-none-any.whl
Size 148.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0e8f3796b7c76b4316d97ae07088c5587f53c5e7b21e4c602ab9b78cebd22246
BLAKE2b-256 checksum
How to use checksums
fe70b93b5af51a0011b61456494e5ccb28cf6392f887f21bc90ad0e17c478293
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2.2.0 This release

2 release files

2.1.2

2 release files

2.1.1

2 release files

2.1.0

2 release files

2.0.1

2 release files

2.0.0

2 release files

1.22.2

2 release files

1.22.0

2 release files

1.21.4

2 release files

1.21.3

2 release files

1.21.2

2 release files

1.21.1

2 release files

1.21.0

2 release files

1.20.0

2 release files

1.19.0

2 release files

1.18.0

2 release files

1.17.4

2 release files

1.17.3

2 release files

1.17.2

2 release files

1.17.1

2 release files

1.14.0

2 release files

1.13.3

2 release files

1.13.2

2 release files

1.13.1

2 release files

1.13.0

2 release files

1.12.1

2 release files

1.12.0

2 release files

1.11.0

2 release files

1.10.1

2 release files

1.10.0

2 release files

1.9.2

2 release files

1.9.1

2 release files

1.9.0

2 release files

1.8.3

2 release files

1.8.2

2 release files

1.8.1

2 release files

1.8.0

2 release files

1.7.2

2 release files

1.7.1

2 release files

1.7.0

2 release files

1.6.0

2 release files

1.5.0

2 release files

1.4.1

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.0

2 release files

0.38.1

2 release files

0.38.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page