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 | countdown on an LED panel | | [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 +![A countdown to Christmas on a 128x32 panel: a gift image on the left, the +name and "113 Days" on the right](../../docs/assets/countdown/hero.png) -- **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` | + +![Six panels showing each rung of that ladder: 113 Days, Tomorrow, 10h 30m, +30m, NOW! and 3d ago](../../docs/assets/countdown/count-formats.png) + +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 + +![Four panels showing image-left, image-right, text-only and +image-only](../../docs/assets/countdown/layout-presets.png) + +`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. + +![Three panels showing text_align set to left, center and +right](../../docs/assets/countdown/text-align.png) + +### `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. + +![Two panels: until mode counting down to New Year, since mode counting up from +a launch date](../../docs/assets/countdown/modes.png) + +### 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: + +![Four panels showing press_start, four_by_six, five_by_seven and tom_thumb +rendering the same countdown](../../docs/assets/countdown/fonts.png) + +| 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 +![The same countdown on 64x32, 128x32, 128x64 and 256x32 +panels](../../docs/assets/countdown/panel-sizes.png) -### 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" }