diff --git a/README.md b/README.md
index 13432c47..75604af4 100644
--- a/README.md
+++ b/README.md
@@ -150,7 +150,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
| Plugin | Description | Preview |
|--------|-------------|---------|
| [Flight Tracker](./plugins/ledmatrix-flights/) | Real-time ADS-B aircraft tracking with map display | |
-| [Countdown Display](./plugins/countdown/) | Customizable countdowns for birthdays, events, holidays | |
+| [Countdown Display](./plugins/countdown/) | Customizable countdowns for birthdays, events, holidays |
|
| [Election Results](./plugins/ledmatrix-elections/) | Live election results ticker with full-screen race interrupts | |
### Holiday (1)
diff --git a/docs/assets/countdown/count-formats.png b/docs/assets/countdown/count-formats.png
new file mode 100644
index 00000000..e3557725
Binary files /dev/null and b/docs/assets/countdown/count-formats.png differ
diff --git a/docs/assets/countdown/fonts.png b/docs/assets/countdown/fonts.png
new file mode 100644
index 00000000..52a20db2
Binary files /dev/null and b/docs/assets/countdown/fonts.png differ
diff --git a/docs/assets/countdown/hero.png b/docs/assets/countdown/hero.png
new file mode 100644
index 00000000..85af780a
Binary files /dev/null and b/docs/assets/countdown/hero.png differ
diff --git a/docs/assets/countdown/layout-presets.png b/docs/assets/countdown/layout-presets.png
new file mode 100644
index 00000000..d20f459b
Binary files /dev/null and b/docs/assets/countdown/layout-presets.png differ
diff --git a/docs/assets/countdown/modes.png b/docs/assets/countdown/modes.png
new file mode 100644
index 00000000..f38a6850
Binary files /dev/null and b/docs/assets/countdown/modes.png differ
diff --git a/docs/assets/countdown/panel-sizes.png b/docs/assets/countdown/panel-sizes.png
new file mode 100644
index 00000000..4cb30d7f
Binary files /dev/null and b/docs/assets/countdown/panel-sizes.png differ
diff --git a/docs/assets/countdown/sample/gift.png b/docs/assets/countdown/sample/gift.png
new file mode 100644
index 00000000..a4b653a9
Binary files /dev/null and b/docs/assets/countdown/sample/gift.png differ
diff --git a/docs/assets/countdown/shots.json b/docs/assets/countdown/shots.json
new file mode 100644
index 00000000..9bef9713
--- /dev/null
+++ b/docs/assets/countdown/shots.json
@@ -0,0 +1,561 @@
+{
+ "plugin": "countdown",
+ "defaults": {
+ "width": 128,
+ "height": 32,
+ "scale": 6,
+ "freeze_time": "2026-09-02T15:00:00+00:00",
+ "config": {
+ "enabled": true,
+ "show_expired": false
+ }
+ },
+ "shots": [
+ {
+ "name": "hero",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png",
+ "layout_preset": "image-left"
+ }
+ ]
+ }
+ },
+ {
+ "name": "layout-image-left",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png",
+ "layout_preset": "image-left"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "layout-image-right",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png",
+ "layout_preset": "image-right"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "layout-text-only",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png",
+ "layout_preset": "text-only"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "layout-image-only",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png",
+ "layout_preset": "image-only"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "mode-until",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "New Year",
+ "target_date": "2027-01-01",
+ "mode": "until",
+ "layout_preset": "text-only"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "mode-since",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Launched",
+ "target_date": "2026-04-07",
+ "mode": "since",
+ "layout_preset": "text-only"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "align-left",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only",
+ "text_align": "left"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "align-center",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only",
+ "text_align": "center"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "align-right",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only",
+ "text_align": "right"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "size-64x32",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png"
+ }
+ ]
+ },
+ "width": 64,
+ "height": 32,
+ "scale": 8,
+ "standalone": false
+ },
+ {
+ "name": "size-128x32",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png"
+ }
+ ]
+ },
+ "width": 128,
+ "height": 32,
+ "scale": 8,
+ "standalone": false
+ },
+ {
+ "name": "size-128x64",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png"
+ }
+ ]
+ },
+ "width": 128,
+ "height": 64,
+ "scale": 8,
+ "standalone": false
+ },
+ {
+ "name": "size-256x32",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "docs/assets/countdown/sample/gift.png"
+ }
+ ]
+ },
+ "width": 256,
+ "height": 32,
+ "scale": 4,
+ "standalone": false
+ },
+ {
+ "name": "fmt-days",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "layout_preset": "text-only",
+ "name": "Christmas",
+ "target_date": "2026-12-25"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "fmt-tomorrow",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "layout_preset": "text-only",
+ "name": "Results Day",
+ "target_date": "2026-09-04"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "fmt-hours",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "layout_preset": "text-only",
+ "name": "Doors Open",
+ "target_date": "2026-09-02",
+ "target_time": "21:30"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "fmt-minutes",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "layout_preset": "text-only",
+ "name": "Standup",
+ "target_date": "2026-09-02",
+ "target_time": "11:30"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "fmt-now",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "layout_preset": "text-only",
+ "name": "Liftoff",
+ "target_date": "2026-09-02",
+ "target_time": "11:00"
+ }
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "fmt-expired",
+ "config": {
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "layout_preset": "text-only",
+ "name": "Deadline",
+ "target_date": "2026-08-30"
+ }
+ ],
+ "show_expired": true
+ },
+ "standalone": false
+ },
+ {
+ "name": "font-press_start",
+ "standalone": false,
+ "config": {
+ "font_family": "press_start",
+ "name_font_family": "press_start",
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only"
+ }
+ ]
+ }
+ },
+ {
+ "name": "font-four_by_six",
+ "standalone": false,
+ "config": {
+ "font_family": "four_by_six",
+ "name_font_family": "four_by_six",
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only"
+ }
+ ]
+ }
+ },
+ {
+ "name": "font-five_by_seven",
+ "standalone": false,
+ "config": {
+ "font_family": "five_by_seven",
+ "name_font_family": "five_by_seven",
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only"
+ }
+ ]
+ }
+ },
+ {
+ "name": "font-tom_thumb",
+ "standalone": false,
+ "config": {
+ "font_family": "tom_thumb",
+ "name_font_family": "tom_thumb",
+ "countdowns": [
+ {
+ "id": "a",
+ "enabled": true,
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "layout_preset": "text-only"
+ }
+ ]
+ }
+ }
+ ],
+ "composites": [
+ {
+ "name": "layout-presets",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "layout-image-left",
+ "label": "image-left",
+ "sublabel": "the default: image on the left third"
+ },
+ {
+ "shot": "layout-image-right",
+ "label": "image-right",
+ "sublabel": "mirrored"
+ },
+ {
+ "shot": "layout-text-only",
+ "label": "text-only",
+ "sublabel": "any image is ignored"
+ },
+ {
+ "shot": "layout-image-only",
+ "label": "image-only",
+ "sublabel": "no text at all"
+ }
+ ]
+ },
+ {
+ "name": "count-formats",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "fmt-days",
+ "label": "\"113 Days\"",
+ "sublabel": "more than 2 days away"
+ },
+ {
+ "shot": "fmt-tomorrow",
+ "label": "\"Tomorrow\"",
+ "sublabel": "1 to 2 days away -- no number is shown"
+ },
+ {
+ "shot": "fmt-hours",
+ "label": "\"10h 30m\"",
+ "sublabel": "1 to 24 hours away"
+ },
+ {
+ "shot": "fmt-minutes",
+ "label": "\"30m\"",
+ "sublabel": "1 to 60 minutes away"
+ },
+ {
+ "shot": "fmt-now",
+ "label": "\"NOW!\"",
+ "sublabel": "less than a minute away"
+ },
+ {
+ "shot": "fmt-expired",
+ "label": "\"3d ago\"",
+ "sublabel": "past, and only visible with show_expired on"
+ }
+ ]
+ },
+ {
+ "name": "modes",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "mode-until",
+ "label": "mode: until",
+ "sublabel": "the default; counts down to the date"
+ },
+ {
+ "shot": "mode-since",
+ "label": "mode: since",
+ "sublabel": "counts up from a date that has passed"
+ }
+ ]
+ },
+ {
+ "name": "text-align",
+ "columns": 1,
+ "cells": [
+ {
+ "shot": "align-left",
+ "label": "text_align: left",
+ "sublabel": ""
+ },
+ {
+ "shot": "align-center",
+ "label": "text_align: center",
+ "sublabel": "the default"
+ },
+ {
+ "shot": "align-right",
+ "label": "text_align: right",
+ "sublabel": ""
+ }
+ ]
+ },
+ {
+ "name": "fonts",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "font-press_start",
+ "label": "press_start",
+ "sublabel": "the default; chunky 8-bit, scalable"
+ },
+ {
+ "shot": "font-four_by_six",
+ "label": "four_by_six",
+ "sublabel": "scalable; fits more per line"
+ },
+ {
+ "shot": "font-five_by_seven",
+ "label": "five_by_seven",
+ "sublabel": "bitmap, drawn at its native 7px"
+ },
+ {
+ "shot": "font-tom_thumb",
+ "label": "tom_thumb",
+ "sublabel": "bitmap, native 6px -- the smallest"
+ }
+ ]
+ },
+ {
+ "name": "panel-sizes",
+ "columns": 1,
+ "cells": [
+ {
+ "shot": "size-64x32",
+ "label": "64 x 32",
+ "sublabel": "image and text share very little width"
+ },
+ {
+ "shot": "size-128x32",
+ "label": "128 x 32",
+ "sublabel": "the common two-panel chain"
+ },
+ {
+ "shot": "size-128x64",
+ "label": "128 x 64",
+ "sublabel": "the image gets real room"
+ },
+ {
+ "shot": "size-256x32",
+ "label": "256 x 32",
+ "sublabel": "a long chain"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/assets/countdown/text-align.png b/docs/assets/countdown/text-align.png
new file mode 100644
index 00000000..71f2925d
Binary files /dev/null and b/docs/assets/countdown/text-align.png differ
diff --git a/plugins.json b/plugins.json
index 610c1736..a087fd6e 100644
--- a/plugins.json
+++ b/plugins.json
@@ -187,10 +187,10 @@
"plugin_path": "plugins/countdown",
"stars": 0,
"downloads": 0,
- "last_updated": "2026-08-13",
+ "last_updated": "2026-09-02",
"verified": true,
"screenshot": "",
- "latest_version": "3.2.1",
+ "latest_version": "3.3.0",
"icon": "fa-clock"
},
{
diff --git a/plugins/countdown/README.md b/plugins/countdown/README.md
index 391e8859..5f23fc1e 100644
--- a/plugins/countdown/README.md
+++ b/plugins/countdown/README.md
@@ -1,253 +1,393 @@
-# Countdown Plugin for LEDMatrix
+# Countdown
-Display customizable countdowns with images on your LED matrix. Perfect for birthdays, holidays, vacations, events, and any special occasion you want to count down to!
+Count down to the things you care about — birthdays, holidays, a holiday, a
+launch — or count up from a date that has passed. Each countdown gets a name, a
+target, an optional image, and its own styling, and the plugin rotates through
+however many you configure.
-## Features
+
-- **Multiple Countdowns**: Create and manage multiple countdown entries
-- **Custom Images**: Use a unique local image path for each countdown
-- **Individual Control**: Enable/disable each countdown independently
-- **Customizable Display**: Configure fonts, colors, and sizes
-- **Smart Layout**: Image on left 1/3rd, countdown text on right 2/3rds
-- **Auto Rotation**: Automatically cycles through enabled countdowns
-- **Flexible Dates**: Supports future dates with automatic "TODAY!" display
-- **Web UI Management**: Full configuration via LEDMatrix web interface
+*Every image in this README is real plugin output, rendered at the true panel
+size against a frozen clock so the figures are reproducible. The gift icon is a
+plain sample standing in for your own image.*
-## Installation
-
-### From Plugin Store (Recommended)
-
-1. Open the LEDMatrix web interface (`http://your-pi-ip:5000`)
-2. Open the **Plugin Manager** tab
-3. Find **Countdown Display** in the **Plugin Store** section and click
- **Install**
-
-### Manual Installation
-
-1. Copy the plugin from the monorepo:
- ```bash
- cp -r ledmatrix-plugins/plugins/countdown /path/to/LEDMatrix/plugin-repos/
- ```
+---
-2. Restart LEDMatrix or reload plugins via the web UI
+## Table of Contents
-## Configuration
+1. [What's On Screen](#whats-on-screen)
+2. [Installation](#installation)
+3. [Quick Start](#quick-start)
+4. [How the Countdown Value Is Written](#how-the-countdown-value-is-written)
+5. [Per-Countdown Settings](#per-countdown-settings)
+ - [Layout presets](#layout-presets)
+ - [Text alignment](#text-alignment)
+ - [until and since](#until-and-since)
+ - [Per-countdown overrides](#per-countdown-overrides)
+6. [Global Settings](#global-settings)
+ - [Fonts and colours](#fonts-and-colours)
+ - [Images](#images)
+7. [Panel Sizes](#panel-sizes)
+8. [Troubleshooting](#troubleshooting)
+9. [Development](#development)
+10. [Support](#support)
-### Adding a Countdown
+---
-1. Open the LEDMatrix web UI
-2. Open the **Countdown Display** tab (second nav row, added once the
- plugin is installed)
-3. Click "Add Countdown"
-4. Fill in the details:
- - **Name**: Display name (e.g., "Birthday", "Vacation")
- - **Target Date**: The date you're counting down to
- - **Image Path**: Enter a local path (e.g., `assets/plugins/countdown/uploads/birthday.png`)
- - **Enabled**: Toggle to show/hide this countdown
-5. Click Save
+## What's On Screen
-### Configuration Options
+One countdown at a time, drawn as an optional image beside two lines of text:
-The web UI form is generated from `config_schema.json` (the source of truth).
-
-#### Per-Countdown Settings
-
-Each entry in the **Countdowns** table supports:
-
-| Field | Default | Notes |
-|-------|---------|-------|
-| `name` (required) | — | Display name, max 30 chars |
-| `target_date` (required) | — | `YYYY-MM-DD` — the date to count to/from |
-| `target_time` | `00:00` | Optional `HH:MM` (24h) for sub-day precision; switches to hours/minutes as the event nears |
-| `mode` | `until` | `until` counts down to the date (Days Until); `since` counts up from it (Days Since) |
-| `layout_preset` | `image-left` | `image-left`, `image-right`, `text-only`, or `image-only` |
-| `text_align` | `center` | `left`, `center`, or `right` for the name/value text |
-| `image_path` | — | Optional image shown alongside the countdown |
-| `enabled` | `true` | Show/hide this countdown |
-| `display_order` | `0` | Rotation order (lower first) |
-| `layout.*` | auto | Optional pixel position/size overrides for the image and text |
-| `style.*` | inherit | Optional per-countdown font/size/color overrides (`null` inherits the global settings below) |
+```text
+┌──────────────────────────────┐
+│ │ Countdown name │
+│ IMAGE │ │
+│ (1/3) │ 113 Days │
+└──────────────────────────────┘
+```
-#### Global Settings (defaults for all countdowns)
+With several countdowns configured the plugin rotates through them in
+`display_order`, holding each for `display_duration` seconds.
-| Key | Default | Notes |
-|-----|---------|-------|
-| `display_duration` | `15` | Seconds to show each countdown before rotating (5–300) |
-| `font_family` | `press_start` | One of `press_start`, `four_by_six`, `tom_thumb`, `tiny`, `picopixel` |
-| `font_size` | `8` | Countdown value font size (4–16px) |
-| `font_color` | `[255, 255, 255]` | RGB color for the countdown value |
-| `name_font_size` | `8` | Countdown name font size (4–16px) |
-| `name_font_color` | `[200, 200, 200]` | RGB color for the countdown name |
-| `background_color` | `[0, 0, 0]` | RGB background color |
-| `show_expired` | `false` | Show `until` countdowns that have passed (as "Nd ago") |
-| `fit_to_display` | `true` | Auto-scale images to their allocated area |
-| `preserve_aspect_ratio` | `true` | Keep image proportions when scaling |
+---
-## Display Layout
+## Installation
-The plugin uses a split layout for optimal readability:
+**From the Plugin Store (recommended).** Open the LEDMatrix web interface at
+`http://:5000`, go to **Plugin Manager**, find **Countdown Display**
+in the **Plugin Store** section, and click **Install**. Countdowns are managed
+from the plugin's own tab.
-```
-┌──────────────────────────────┐
-│ │ │
-│ IMAGE │ Countdown │
-│ (1/3) │ Name │
-│ │ │
-│ │ 15 Days │
-│ │ │
-└──────────────────────────────┘
-```
+**Manually.** Copy this directory into your LEDMatrix `plugin-repos/` and
+restart the display service.
-### Display Formats
+---
-- **Multiple Days**: "15 Days", "100 Days"
-- **One Day**: "1 Day"
-- **Event Day**: "TODAY!" (shown in bright yellow)
+## Quick Start
-## Examples
+A countdown needs only a `name` and a `target_date` — those two are the schema's
+only required fields:
-### Birthday Countdown
```json
{
- "name": "Mom's Birthday",
- "target_date": "2026-06-15",
- "enabled": true,
- "image_path": "assets/plugins/countdown/uploads/birthday-cake.png"
+ "countdown": {
+ "enabled": true,
+ "countdowns": [
+ { "name": "Christmas", "target_date": "2026-12-25" }
+ ]
+ }
}
```
-### Vacation Countdown
+Several countdowns, with images and ordering:
+
```json
{
- "name": "Hawaii Trip",
- "target_date": "2026-07-20",
- "enabled": true,
- "image_path": "assets/plugins/countdown/uploads/beach.jpg"
+ "countdown": {
+ "enabled": true,
+ "display_duration": 15,
+ "countdowns": [
+ {
+ "name": "Christmas",
+ "target_date": "2026-12-25",
+ "image_path": "assets/countdown/tree.png",
+ "layout_preset": "image-left",
+ "display_order": 1
+ },
+ {
+ "name": "Doors Open",
+ "target_date": "2026-09-02",
+ "target_time": "21:30",
+ "layout_preset": "text-only",
+ "display_order": 2
+ },
+ {
+ "name": "Launched",
+ "target_date": "2026-04-07",
+ "mode": "since",
+ "display_order": 3
+ }
+ ]
+ }
}
```
-### Holiday Countdown
+---
+
+## How the Countdown Value Is Written
+
+The second line adapts to how close the event is. It is **not** always a day
+count, and there is no separate "today" state:
+
+| Time to target | Renders as | Example |
+|----------------|------------|---------|
+| More than 2 days | `N Days` | `113 Days` |
+| 1 to 2 days | `Tomorrow` | *(no number at all)* |
+| 1 to 24 hours | `Nh Nm` | `10h 30m` |
+| 1 to 60 minutes | `Nm` | `30m` |
+| Under a minute | `NOW!` | |
+| Already passed | `Nd ago` | `3d ago` |
+
+
+
+Two consequences worth knowing:
+
+- **The hours-and-minutes rungs only appear if you set `target_time`.** Without
+ it the target is midnight, so an event "today" is already in the past by the
+ time anyone is looking at the panel. Set `target_time` for anything where the
+ hour matters.
+- **A passed countdown is hidden by default.** `Nd ago` is only ever visible
+ with [`show_expired`](#global-settings) turned on.
+
+In `since` mode the same granularity applies with elapsed wording: `Just now`,
+`Nm ago`, `Nh Nm ago`, `N Days ago`.
+
+---
+
+## Per-Countdown Settings
+
+Each object in the `countdowns` array takes these:
+
+| Key | Type | Default | What it does |
+|-----|------|---------|--------------|
+| `name` | string | *required* | The label on the top line |
+| `target_date` | string | *required* | `YYYY-MM-DD`. The date to count to, or from in `since` mode |
+| `target_time` | string | `"00:00"` | `HH:MM`, 24-hour. Gives sub-day precision — see the table above |
+| `enabled` | boolean | `true` | Include this entry in the rotation |
+| `mode` | string | `until` | `until` counts down; `since` counts up |
+| `layout_preset` | string | `image-left` | `image-left`, `image-right`, `text-only`, `image-only` |
+| `text_align` | string | `center` | `left`, `center`, `right` — within the text area |
+| `image_path` | string / null | `null` | Image to show beside the text |
+| `display_order` | integer | `0` | Rotation order; lower goes first |
+| `id` | string | *auto* | Unique identifier, generated for you |
+| `layout` | object / null | `null` | Pixel position and size overrides |
+| `style` | object / null | `null` | Per-entry font and colour overrides |
+
+### Layout presets
+
+
+
+`text-only` ignores any `image_path` you have set rather than erroring, so you
+can switch a countdown to text without deleting its image. `image-only` draws
+no name and no value — useful as a static picture in the rotation.
+
+### Text alignment
+
+`text_align` positions the name and value inside the text area, which is the
+whole panel under `text-only` and the remaining two thirds otherwise.
+
+
+
+### `until` and `since`
+
+`until` (the default) counts down to `target_date`. `since` counts up from it,
+which is what you want for "days since we shipped" or an anniversary.
+
+
+
+### Per-countdown overrides
+
+`style` overrides the global fonts and colours for one entry. Every key is
+`null` by default, meaning "inherit the global setting":
+
```json
{
- "name": "Christmas",
- "target_date": "2026-12-25",
- "enabled": true,
- "image_path": "assets/plugins/countdown/uploads/christmas-tree.png"
+ "name": "Birthday",
+ "target_date": "2026-11-14",
+ "style": { "font_color": [255, 105, 180], "name_font_color": [255, 182, 193] }
}
```
-## Image Guidelines
+`style` accepts `font_family`, `font_size`, `font_color`, `name_font_family`,
+`name_font_size`, `name_font_color` and `background_color`.
-- **Supported Formats**: PNG, JPEG, BMP, GIF
-- **Recommended Size**: Images will be scaled to fit left 1/3 of display
-- **Transparency**: PNG transparency is supported
-- **Max File Size**: 5MB per image
-- **Best Practice**: Use square or portrait-oriented images for best fit
+`layout` overrides the automatic positioning, in pixels. Leave it out unless
+something sits wrong — the defaults place the image on the left third and the
+text on the right two thirds.
-## Troubleshooting
+| Key | Default | Meaning |
+|-----|---------|---------|
+| `image_x` / `image_y` | `0` | Image top-left corner |
+| `image_width` | `0` | `0` means auto — a third of the panel width |
+| `image_height` | `0` | `0` means auto — the full panel height |
+| `name_x` / `name_y` | `null` | `null` means auto: centred in the text area, upper third |
+| `value_x` / `value_y` | `null` | `null` means auto: centred, lower two thirds |
+
+Both `layout` and `style` also accept `null` or an empty string, which the web
+UI may write when you clear a field.
+
+---
+
+## Global Settings
+
+These apply to every countdown that does not override them.
+
+| Option | Type | Default | What it does |
+|--------|------|---------|--------------|
+| `enabled` | boolean | `true` | Whether the plugin runs at all |
+| `display_duration` | number | `15` | Seconds each countdown holds the panel |
+| `show_expired` | boolean | `false` | Keep showing a countdown after its date has passed |
+| `fit_to_display` | boolean | `true` | Scale images to fit the space they are given |
+| `preserve_aspect_ratio` | boolean | `true` | Keep image proportions when scaling |
+| `background_color` | array | `[0, 0, 0]` | Panel background, `[R, G, B]` |
+| `font_family` | string | `press_start` | Face for the countdown value |
+| `font_size` | integer | `8` | Size for the countdown value |
+| `font_color` | array | `[255, 255, 255]` | Colour for the countdown value |
+| `name_font_family` | string / null | `null` | Face for the name; `null` inherits `font_family` |
+| `name_font_size` | integer | `8` | Size for the name |
+| `name_font_color` | array | `[200, 200, 200]` | Colour for the name |
+
+`show_expired` is the one to reach for if a countdown vanishes the moment it
+lands. With it off — the default — a passed entry is dropped from the rotation
+entirely, and if it was the only one the panel shows "No Active Countdowns".
+
+### Fonts and colours
+
+Four faces are available, and all four render distinctly:
+
+
+
+| Family | Kind | Notes |
+|--------|------|-------|
+| `press_start` | Scalable | The default; chunky 8-bit, very legible across a room |
+| `four_by_six` | Scalable | Fits noticeably more text per line |
+| `five_by_seven` | Bitmap | Crisp; always drawn at its native 7px |
+| `tom_thumb` | Bitmap | The smallest option; native 6px |
+
+**`font_size` only affects the scalable faces.** A bitmap font exists at exactly
+one pixel size, so `five_by_seven` and `tom_thumb` are drawn at the size their
+file declares and ignore `font_size`. That is why they look sharp while a
+scalable face at an awkward size looks soft.
-### Countdown Not Showing
-- Verify the countdown is **enabled** in the configuration
-- Check that the target date is in the correct format (YYYY-MM-DD)
-- Ensure at least one countdown is enabled
+Name and value are styled separately, so a dimmer name over a bright value
+(the default) reads well at a glance. Setting `name_font_family` to `null`
+makes the name inherit `font_family`.
-### Image Not Displaying
-- Verify the configured image path exists on disk
-- Check image format is supported (PNG, JPG, BMP, GIF)
-- Confirm the path is readable by the LEDMatrix process
-- Check LEDMatrix logs for image loading errors
+### Images
-### Wrong Date Calculation
-- Verify date format is YYYY-MM-DD
-- Check your system date/time is correct
-- Dates are calculated based on midnight local time
+`image_path` may be absolute, relative to the working directory, or relative to
+the plugins repository root — the plugin tries each in that order.
-### Font Too Large/Small
-- Adjust `font_size` and `name_font_size` in configuration
-- Range is 6-16 pixels
-- Smaller fonts work better on smaller displays
+- Square images work best; the image area is a third of the panel width by the
+ full height.
+- PNG with transparency is fine. Transparent pixels show `background_color`.
+- `fit_to_display` scales the image into its area; `preserve_aspect_ratio`
+ keeps it from stretching. Both default to on, which is what you want unless
+ you are deliberately filling the area.
+- An image that cannot be found logs a warning and the countdown draws as
+ text — it does not fail the render.
-## Technical Details
+---
-### API Version
-- LEDMatrix API: 1.0.0
-- Compatible with LEDMatrix >=2.0.0
+## Panel Sizes
-### Dependencies
-- Pillow >= 9.0.0
-- python-dateutil >= 2.8.0
+
-### Plugin Architecture
-- Inherits from `BasePlugin`
-- Uses font manager for text rendering
-- Implements dynamic duration for smooth rotation
-- Caches loaded images for performance
+- **64×32** leaves the image about 21 pixels of width. Consider `text-only` at
+ this size, or a very simple image.
+- **128×32** is the size the defaults are tuned for.
+- **128×64** gives the image real room and separates the two text lines.
+- **256×32** keeps the same proportions across a longer panel.
-### File Locations
-- **Plugin Directory**: `plugin-repos/countdown/`
-- **Suggested Image Directory**: `assets/plugins/countdown/uploads/` (example location only; set each countdown's `image_path` explicitly in plugin configuration)
-- **Configuration**: Stored in LEDMatrix `config.json`
+---
+
+## Troubleshooting
+
+**Nothing shows, or "No Active Countdowns".**
+Every countdown has either been disabled or has passed its date. Check
+`enabled` on each entry, and turn on `show_expired` if you want passed ones to
+stay.
+
+**A countdown disappeared the day it arrived.**
+That is `show_expired` (off by default). With no `target_time`, the target is
+midnight, so the entry expires at the start of the day rather than the end.
+
+**It says "Tomorrow" and I want a number.**
+That is the format ladder, not a fault — between 1 and 2 days out the plugin
+writes `Tomorrow` rather than `1 Day`. See
+[the table above](#how-the-countdown-value-is-written).
+
+**The image is not showing.**
+Check the log for `Image not found` — the path is tried absolute, then relative
+to the working directory, then relative to the repository root. Also check
+`layout_preset` is not `text-only`, which ignores images by design.
+
+**I picked a font and nothing changed.**
+On a current version all four families work. Older versions offered `tiny` and
+`picopixel`, which had no font file at all, and silently fell back to the
+default for every bitmap family. If you had one of those selected, pick again.
+
+**The date arithmetic looks off by a day.**
+Countdowns are computed in the host's local time against `target_date` at
+`target_time` (midnight if unset). A target early in the morning can therefore
+tick over a day sooner than you expect.
+
+---
## Development
-### Project Structure
-```
+### Project structure
+
+```text
countdown/
-├── manifest.json # Plugin metadata
-├── config_schema.json # Configuration schema
-├── manager.py # Plugin implementation
-├── requirements.txt # Python dependencies
-└── README.md # This file
+├── manifest.json # Plugin metadata and version history
+├── manager.py # CountdownPlugin
+├── config_schema.json # Settings schema; source of truth for defaults
+├── requirements.txt
+├── test/ # Harness config and golden images
+└── README.md
```
-### Key Methods
-- `update()`: Recalculates all countdown values
-- `display()`: Renders current countdown with image and text
-- `_calculate_time_remaining()`: Computes days/hours/minutes to target
-- `_load_and_scale_image()`: Loads and scales images to fit layout
+### Requirements and compatibility
+
+| | |
+|---|---|
+| LEDMatrix core | `>= 2.0.0` |
+| Pillow | `>= 12.2.0` |
+| python-dateutil | `>= 2.8.0` |
+
+The plugin subclasses `BasePlugin`, renders text through the core font manager,
+supports dynamic duration so the rotation stays smooth, and **caches loaded
+images** so a countdown that comes round repeatedly does not re-decode its
+picture each time.
-## Contributing
+### Where to keep images
-Feel free to submit issues, feature requests, or pull requests!
+There is no enforced location — `image_path` is whatever you set. A convention
+that keeps uploads out of the plugin directory (and so survives a plugin
+update) is:
-## License
+```text
+assets/plugins/countdown/uploads/
+```
+
+### Regenerating the images in this README
-Released under the GNU General Public License v3.0 — see the LICENSE file for details.
+Every screenshot comes from a declarative shot list against a frozen clock:
-## Credits
+```bash
+python scripts/render_docs_assets.py --plugin countdown
+```
-Created for the LEDMatrix project by Charles
+`--check` verifies the committed images still match what the plugin renders.
+The sample gift icon lives in `docs/assets/countdown/sample/`.
-Inspired by the static-image plugin for image handling patterns.
+---
## Support
-For issues or questions:
-1. Check the LEDMatrix documentation
-2. Review the troubleshooting section above
-3. Check LEDMatrix logs for error messages
-4. Open an issue on the repository
-
-## Changelog
-
-### Version 2.0.0
-- Breaking: countdown image configuration is now path-based only (no per-row upload widget in web UI)
-- Updated documentation to use `image_path` text input workflow
-
-### Version 1.0.2
-- Removed redundant legacy image fallback in `display()` and rely on normalized `image_path`
-- Improved cache invalidation to refresh images when countdown metadata changes (not only count changes)
-- Added strict date-schema note and manifest version history metadata
-
-### Version 1.0.1
-- Fixed web UI schema for countdown table editing
-- Improved config normalization (auto-generate IDs and migrate legacy image format)
-
-### Version 1.0.0
-- Initial release
-- Multiple countdown support
-- Custom image uploads per countdown
-- Configurable fonts and colors
-- Split layout (image left 1/3, text right 2/3)
-- Auto-rotation through enabled countdowns
-- "TODAY!" special display for event day
+- YouTube:
+- Instagram:
+- Discord:
+- Sponsor: [GitHub Sponsors](https://github.com/sponsors/ChuckBuilds) ·
+ [Buy Me a Coffee](https://buymeacoffee.com/chuckbuilds) ·
+ [Ko-fi](https://ko-fi.com/chuckbuilds/)
+
+Released under the GNU General Public License v3.0 — see [LICENSE](LICENSE).
diff --git a/plugins/countdown/config_schema.json b/plugins/countdown/config_schema.json
index d7eda80b..991eb7d0 100644
--- a/plugins/countdown/config_schema.json
+++ b/plugins/countdown/config_schema.json
@@ -240,9 +240,8 @@
"enum": [
"press_start",
"four_by_six",
+ "five_by_seven",
"tom_thumb",
- "tiny",
- "picopixel",
null
],
"default": null,
@@ -288,9 +287,8 @@
"enum": [
"press_start",
"four_by_six",
+ "five_by_seven",
"tom_thumb",
- "tiny",
- "picopixel",
null
],
"default": null,
@@ -359,9 +357,8 @@
"enum": [
"press_start",
"four_by_six",
- "tom_thumb",
- "tiny",
- "picopixel"
+ "five_by_seven",
+ "tom_thumb"
],
"default": "press_start",
"description": "Global font family for countdown text (can be overridden per countdown)"
@@ -404,9 +401,8 @@
"enum": [
"press_start",
"four_by_six",
+ "five_by_seven",
"tom_thumb",
- "tiny",
- "picopixel",
null
],
"default": null,
diff --git a/plugins/countdown/manager.py b/plugins/countdown/manager.py
index 2fb1adca..315b49c8 100644
--- a/plugins/countdown/manager.py
+++ b/plugins/countdown/manager.py
@@ -293,8 +293,40 @@ def _register_fonts(self):
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
"four_by_six": "assets/fonts/4x6-font.ttf",
"five_by_seven": "assets/fonts/5x7.bdf",
+ "tom_thumb": "assets/fonts/tom-thumb.bdf",
}
+ @staticmethod
+ def _bdf_pixel_size(path) -> Optional[int]:
+ """The pixel size a .bdf font declares, or None if it does not."""
+ try:
+ with open(path, "r", encoding="latin-1") as handle:
+ for line in handle:
+ if line.startswith("PIXEL_SIZE"):
+ return int(line.split()[1])
+ if line.startswith("CHARS"):
+ break # past the header
+ except (OSError, ValueError, IndexError):
+ return None
+ return None
+
+ @classmethod
+ def _truetype_or_native(cls, path, size_px: int):
+ """Load a font, falling back to a bitmap face's own pixel size.
+
+ A .bdf exists at exactly one size, and FreeType rejects any other with
+ "invalid pixel size". Asking for the configured font_size therefore
+ failed for every bitmap family, and the text silently fell back to the
+ default face -- so picking one of them appeared to do nothing.
+ """
+ try:
+ return ImageFont.truetype(str(path), size_px)
+ except OSError:
+ native = cls._bdf_pixel_size(path)
+ if native is None or native == size_px:
+ raise
+ return ImageFont.truetype(str(path), native)
+
def _load_family_font_direct(self, family: str, size_px: int):
"""Load a family's font file without relying on the process cwd.
@@ -325,8 +357,9 @@ def _load_family_font_direct(self, family: str, size_px: int):
for candidate in candidates:
try:
if candidate.exists():
- font = ImageFont.truetype(str(candidate), int(size_px))
- break
+ font = self._truetype_or_native(candidate, int(size_px))
+ if font is not None:
+ break
except Exception as exc:
self.logger.debug("Font candidate %s failed to load: %s", candidate, exc)
continue
@@ -388,7 +421,16 @@ def _resolve_font(self, countdown_id: str, role: str, family: str, size_px: int,
# signal. Detect the miss via the catalog and prefer loading
# the real file cwd-independently so rendering doesn't depend
# on the working directory.
- if font is not None and self._is_family_missing(fm, family):
+ # Bitmap families go through our own loader even when the
+ # catalog has them: resolve_font asks for size_px, FreeType
+ # rejects any .bdf at a size other than the one it was drawn
+ # at, and the degraded default comes back looking like a
+ # successful load. _load_family_font_direct retries at the
+ # face's own PIXEL_SIZE.
+ if font is not None and (
+ self._is_family_missing(fm, family)
+ or str(self._FAMILY_FILES.get(family, "")).endswith(".bdf")
+ ):
direct = self._load_family_font_direct(family, size_px)
if direct is not None:
return direct
diff --git a/plugins/countdown/manifest.json b/plugins/countdown/manifest.json
index 7f91125e..b298d471 100644
--- a/plugins/countdown/manifest.json
+++ b/plugins/countdown/manifest.json
@@ -1,7 +1,7 @@
{
"id": "countdown",
"name": "Countdown Display",
- "version": "3.2.1",
+ "version": "3.3.0",
"author": "ChuckBuilds",
"description": "Create and manage customizable countdowns with images. Perfect for birthdays, holidays, events, and special occasions.",
"entry_point": "manager.py",
@@ -21,6 +21,12 @@
">=2.0.0"
],
"versions": [
+ {
+ "version": "3.3.0",
+ "released": "2026-09-02",
+ "ledmatrix_min_version": "2.0.0",
+ "notes": "Make every font in the picker actually work. Only two of the five offered families rendered: tiny and picopixel have no font file anywhere, and every bitmap family fell back silently because a .bdf exists at exactly one pixel size and the loader asked for font_size. Bitmap faces are now loaded at the size the file's PIXEL_SIZE header declares, and bitmap families use the plugin's own loader even when the core's catalog claims them -- the core resolver degrades to the default face without signalling it, which is why five_by_seven silently did nothing. tom_thumb is wired up (the file was always shipped, just unmapped), and the enum now lists the four families that verifiably render distinctly: press_start, four_by_six, five_by_seven, tom_thumb. Also rewrites the README around real rendered screenshots and documents all 40 settings, including the countdown-value ladder, which the old README got wrong: there is no '1 Day' and no 'TODAY!' string -- between one and two days out the plugin writes 'Tomorrow', and a passed countdown reads 'Nd ago' and is hidden unless show_expired is on."
+ },
{
"version": "3.2.1",
"released": "2026-08-13",
@@ -93,5 +99,5 @@
"license": "GPL-3.0",
"homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/countdown",
"config_schema": "config_schema.json",
- "last_updated": "2026-08-13"
+ "last_updated": "2026-09-02"
}