diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eddfc650..a6a73d09 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -43,8 +43,8 @@ checked box. --> forgetting this means users won't receive the change) - [ ] `class_name` in `manifest.json` matches the actual class in `manager.py` exactly (case-sensitive, no spaces) -- [ ] `entry_point` matches the real file (or is omitted to use - the `manager.py` default) +- [ ] `entry_point` matches the real file (the core manifest schema + requires it) - [ ] Updated the plugin's `README.md` if config keys changed - [ ] `config_schema.json` is the source of truth for the web UI form — any new option is in the schema with a `default`, @@ -57,7 +57,10 @@ checked box. --> - [ ] Plugin id matches the directory name and is unique - [ ] `manifest.json` has all required fields (`id`, `name`, - `version`, `class_name`, `display_modes`) + `version`, `author`, `entry_point`, `class_name`, + `compatible_versions`, plus `display_modes`) +- [ ] Added the plugin's entry to `plugins.json` (new plugins are the + one hand-added case; `update_registry.py --check` fails without it) - [ ] `manager.py` inherits from `BasePlugin` and implements `update()` and `display()` - [ ] `config_schema.json` exists and validates as JSON Schema Draft-7 diff --git a/CLAUDE.md b/CLAUDE.md index 6299fbaa..62b6db5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,9 @@ These are not general Python advice — they exist because of how *this* stack w 2. **Never hand-edit `plugins.json`.** Commit with the pre-commit hook (`cp scripts/pre-commit .git/hooks/pre-commit`) or run `python update_registry.py`. CI also regenerates on push to `main`. + Exception: a **new** plugin's entry (and review fields like `verified`) is + added by hand — the script only updates existing entries, and `--check` + fails without one (docs topic 07). 3. **Fetch in `update()`, draw in `display()`.** Never hit the network from `display()`. Cache network data via `self.cache_manager`, keys namespaced by plugin id. @@ -105,8 +108,10 @@ Plugin class: subclass `BasePlugin` from the core (`src.plugin_system.base_plugin.BasePlugin`). Constructor args: `plugin_id, config, display_manager, cache_manager, plugin_manager`. -Required manifest fields: `id` (matches directory), `name`, `version`, -`class_name`, `display_modes`. Full field list / schema conventions → +Required manifest fields (core `schema/manifest_schema.json`, validated in CI): +`id` (matches directory), `name`, `version`, `author`, `entry_point`, +`class_name`, `compatible_versions` — plus `display_modes`, which every plugin +needs to be shown. Full field list / schema conventions → `docs/plugin-development/06-manifest-and-config-schema.md`. Config schemas are JSON Schema Draft-07 with UI `x-*` extensions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 339a6431..10832349 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,8 +109,10 @@ short version: - `class_name` must match the actual class name in `manager.py` **exactly** (case-sensitive, no spaces — see [VERIFICATION.md](VERIFICATION.md) for why this matters) - - Set `entry_point` (defaults to `manager.py` if omitted) - - Set `version`, `author`, `category`, `tags`, `display_modes` + - Set `entry_point` (usually `manager.py`; the core manifest schema CI + validates against requires it) + - Set `version`, `author`, `compatible_versions`, `category`, `tags`, + `display_modes` 4. Implement `update()` and `display()` in your plugin class. 5. Define the configuration schema in `config_schema.json`. The web UI form is generated automatically from this — every key you want @@ -119,8 +121,12 @@ short version: 6. Write a `README.md` covering: what the plugin does, install, configuration, and any external service / API key requirements. 7. Add a `LICENSE` (usually a copy of the project GPL-3.0). -8. Test locally via the symlink + dev_server flow above. -9. Open a PR. +8. Add the plugin's entry to `plugins.json` by hand (copy a neighbour's + shape, `plugin_path: "plugins/"`) — `update_registry.py` only + updates existing entries, and CI's `update_registry.py --check` fails a + new plugin directory without one. +9. Test locally via the symlink + dev_server flow above. +10. Open a PR. ## Commit message convention @@ -136,9 +142,11 @@ specific plugin later. ## Testing -Per-plugin tests live in the LEDMatrix repo at `test/plugins/`. If -you're adding a test for your plugin, open a corresponding PR in -LEDMatrix. The dev preview server (`scripts/dev_server.py` in +Per-plugin unit tests live with the plugin, as `plugins//test_*.py` (or +under its `test/` directory); CI runs them with `scripts/run_plugin_tests.py` +for every plugin a PR changes. Safety-harness fixtures and golden images go in +`plugins//test/` (see +[topic 7](docs/plugin-development/07-testing-ci-and-registry.md)). The dev preview server (`scripts/dev_server.py` in LEDMatrix) is the fastest way to iterate visually — its **All Sizes** button renders your plugin at every harness panel size side by side. @@ -148,7 +156,7 @@ check but fails users. Use the adaptive layout system (`docs/ADAPTIVE_LAYOUT.md` in LEDMatrix: `self.layout`, `draw_fit`, `draw_image`, `scoreboard_regions`) and check the harness's `fill warn` output in `check_plugin.py` reports. Declare your layout's -design size in the manifest (`"display": {"design_size": ...}`) and, once +design size in the manifest (`"display": {"design_size": {"width": 128, "height": 32}}`) and, once your plugin is adaptive, opt into strict checking via `test/harness.json`: `{"fill_check": "strict"}`. See [docs/plugin-development/05-adaptive-layout.md](docs/plugin-development/05-adaptive-layout.md). @@ -157,12 +165,18 @@ your plugin is adaptive, opt into strict checking via - **Plugin Safety** (`test-plugins.yml`): for each changed plugin, enforces the version bump, validates `manifest.json` against the core schema, installs its - `requirements.txt`, and runs the safety harness across all matrix sizes. -- **Module Collisions** (`module-collisions.yml`): runs - `check_module_collisions.py` across all plugins. - -A changed plugin whose code (anything outside `test/`, including its README) is -not accompanied by a `version` bump **fails the PR**. + `requirements.txt`, runs the safety harness across all matrix sizes, and runs + its unit tests; on every run it also checks `plugins.json` against the + manifests (`update_registry.py --check`) and runs every `scripts/test_*.py`. +- **Plugin Structure** (`module-collisions.yml`): runs + `check_module_collisions.py` and the other all-plugin structural checks. +- **Sports Lineage Drift** (`sports-drift.yml`): on scoreboard changes, fails + when copies of a shared sports function that agreed start to disagree. + +A changed plugin whose code (anything outside `test/` and root-level +`test_*.py`, including its README) is not accompanied by a `version` bump +**fails the PR**. Details: +[topic 7](docs/plugin-development/07-testing-ci-and-registry.md#ci-workflows). ## Code of Conduct diff --git a/README.md b/README.md index 777859c0..60dd8d31 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \ | [Masters Tournament](./plugins/masters-tournament/) | Live Masters golf leaderboard, hole tracking, player cards | masters-tournament on an LED panel | | [NFL Draft](./plugins/nfl-draft/) | Projected & live NFL draft picks from ESPN | nfl-draft on an LED panel | | [March Madness](./plugins/march-madness/) | NCAA tournament bracket tracker with round branding and live scores | march-madness on an LED panel | -| [NFL Stat Leaders](./plugins/nfl-stat-leaders/) | Scrolling NFL statistical leaderboards: passing, rushing & receiving yards and TDs | nfl-stat-leaders on an LED panel | +| [NFL Stat Leaders](./plugins/nfl-stat-leaders/) | Scrolling NFL statistical leaderboards: passing, rushing & receiving yards and TDs, plus receptions, sacks, INTs, tackles & passer rating | nfl-stat-leaders on an LED panel | | [Fantasy Blitz](./plugins/fantasy-blitz/) | Arcade-style NFL fantasy football: top scorers as player cards, big plays, busts, waiver pickups and injuries | fantasy-blitz on an LED panel | | [Sports Leaderboard](./plugins/ledmatrix-leaderboard/) | League standings, rankings, conference records | ledmatrix-leaderboard on an LED panel | | [Olympics Countdown](./plugins/olympics/) | Countdown to next Olympics with live medal counts | olympics on an LED panel | @@ -354,7 +354,11 @@ Optional but recommended: |-------|------|-------------| | `id` | string | Unique plugin identifier | | `name` | string | Human-readable name | +| `version` | string | Semver, e.g. `1.0.0` | +| `author` | string | Plugin author | +| `entry_point` | string | Python file holding the plugin class, e.g. `manager.py` | | `class_name` | string | Plugin class name (must match class in entry point) | +| `compatible_versions` | array | LEDMatrix version constraints, e.g. `[">=2.0.0"]` | | `display_modes` | array | Display mode names | See the [manifest schema](https://github.com/ChuckBuilds/LEDMatrix/blob/main/schema/manifest_schema.json) for complete field reference. diff --git a/SUBMISSION.md b/SUBMISSION.md index 13862e98..915bcc80 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -7,7 +7,9 @@ Want to add your plugin to the official registry? Follow these steps! Before submitting, ensure your plugin: - ✅ Has a complete `manifest.json` with all required fields - (`id`, `name`, `version`, `class_name`, `display_modes`) + (`id`, `name`, `version`, `author`, `entry_point`, `class_name`, + `compatible_versions` — the core manifest schema requires these — plus + `display_modes`) - ✅ Follows the [plugin development guide](docs/plugin-development/) - ✅ Has comprehensive README documentation - ✅ Includes example configuration @@ -36,10 +38,17 @@ Submit a PR to add your plugin directly to this repository: README.md ``` -3. **Submit Pull Request** +3. **Add a `plugins.json` entry** + `update_registry.py` only updates entries that already exist, so a new + plugin's entry is the one thing added by hand: copy a neighbour's shape with + `plugin_path: "plugins/your-plugin-id"`. CI (`update_registry.py --check`) + fails a PR that adds a plugin directory without one. See + [the registry](docs/plugin-development/07-testing-ci-and-registry.md#the-registry-pluginsjson). + +4. **Submit Pull Request** Create PR with title: "Add plugin: your-plugin-name" -After approval, your plugin will be added to `plugins.json` and available in the Plugin Store. +After approval and merge, your plugin is available in the Plugin Store. ### Option B: Keep Your Own Repository (3rd-Party) diff --git a/VERIFICATION.md b/VERIFICATION.md index 8a91eac1..478ec281 100644 --- a/VERIFICATION.md +++ b/VERIFICATION.md @@ -18,14 +18,16 @@ module-collision check. Use this list for the human judgment CI can't make ## Manifest Validation -- [ ] All required fields present (`id`, `name`, `version`, `class_name`, - `display_modes`) +- [ ] All required fields present (`id`, `name`, `version`, `author`, + `entry_point`, `class_name`, `compatible_versions` per the core + manifest schema, plus `display_modes`) - [ ] `class_name` matches the actual class name in the entry point (case-sensitive, no spaces) — the loader does `getattr(module, class_name)` and will fail with `AttributeError` otherwise -- [ ] `entry_point` either matches the real file name or is omitted - (defaults to `manager.py`) +- [ ] `entry_point` matches the real file name (the loader defaults to + `manager.py` when it is absent, but the core manifest schema CI + validates against requires it) - [ ] `id` matches the directory name - [ ] Valid JSON syntax - [ ] Correct version format (semver) diff --git a/docs/assets/7-segment-clock/colors.png b/docs/assets/7-segment-clock/colors.png index b52e2f3f..eef9b9b8 100644 Binary files a/docs/assets/7-segment-clock/colors.png and b/docs/assets/7-segment-clock/colors.png differ diff --git a/docs/assets/7-segment-clock/digit-spacing.png b/docs/assets/7-segment-clock/digit-spacing.png index afec5c22..3d3d3ce7 100644 Binary files a/docs/assets/7-segment-clock/digit-spacing.png and b/docs/assets/7-segment-clock/digit-spacing.png differ diff --git a/docs/assets/7-segment-clock/panel-sizes.png b/docs/assets/7-segment-clock/panel-sizes.png index 3827424c..ad6b8545 100644 Binary files a/docs/assets/7-segment-clock/panel-sizes.png and b/docs/assets/7-segment-clock/panel-sizes.png differ diff --git a/docs/assets/7-segment-clock/separator.png b/docs/assets/7-segment-clock/separator.png index fb909da8..c23cbcee 100644 Binary files a/docs/assets/7-segment-clock/separator.png and b/docs/assets/7-segment-clock/separator.png differ diff --git a/docs/assets/7-segment-clock/shots.json b/docs/assets/7-segment-clock/shots.json index 219d8159..b715dd82 100644 --- a/docs/assets/7-segment-clock/shots.json +++ b/docs/assets/7-segment-clock/shots.json @@ -289,12 +289,12 @@ { "shot": "narrow-spacing-2", "label": "64 x 32, spacing 2", - "sublabel": "fits with room to spare" + "sublabel": "the digits fill the height" }, { "shot": "narrow-spacing-10", "label": "64 x 32, spacing 10", - "sublabel": "too wide: the outer digits are clipped" + "sublabel": "still fits, but the digits shrink to make room" } ] } diff --git a/docs/assets/7-segment-clock/time-formats.png b/docs/assets/7-segment-clock/time-formats.png index e9bbf318..dfdd6a3b 100644 Binary files a/docs/assets/7-segment-clock/time-formats.png and b/docs/assets/7-segment-clock/time-formats.png differ diff --git a/docs/assets/christmas-countdown/countdown.png b/docs/assets/christmas-countdown/countdown.png index 4bbcab5b..421c6de1 100644 Binary files a/docs/assets/christmas-countdown/countdown.png and b/docs/assets/christmas-countdown/countdown.png differ diff --git a/docs/assets/christmas-countdown/hero.png b/docs/assets/christmas-countdown/hero.png index 31795aa3..f74f6097 100644 Binary files a/docs/assets/christmas-countdown/hero.png and b/docs/assets/christmas-countdown/hero.png differ diff --git a/docs/assets/christmas-countdown/panel-sizes.png b/docs/assets/christmas-countdown/panel-sizes.png index 6f0b9a79..167b7c35 100644 Binary files a/docs/assets/christmas-countdown/panel-sizes.png and b/docs/assets/christmas-countdown/panel-sizes.png differ diff --git a/docs/assets/christmas-countdown/text-color.png b/docs/assets/christmas-countdown/text-color.png index decd6f57..a8935495 100644 Binary files a/docs/assets/christmas-countdown/text-color.png and b/docs/assets/christmas-countdown/text-color.png differ diff --git a/docs/assets/hello-world/colors.png b/docs/assets/hello-world/colors.png index dbac319e..4d21757d 100644 Binary files a/docs/assets/hello-world/colors.png and b/docs/assets/hello-world/colors.png differ diff --git a/docs/assets/hello-world/hero.png b/docs/assets/hello-world/hero.png index df72947c..ea751a74 100644 Binary files a/docs/assets/hello-world/hero.png and b/docs/assets/hello-world/hero.png differ diff --git a/docs/assets/hello-world/message-length.png b/docs/assets/hello-world/message-length.png index d17f292c..db1ca2ce 100644 Binary files a/docs/assets/hello-world/message-length.png and b/docs/assets/hello-world/message-length.png differ diff --git a/docs/assets/hello-world/panel-sizes.png b/docs/assets/hello-world/panel-sizes.png index a28af521..d15d272b 100644 Binary files a/docs/assets/hello-world/panel-sizes.png and b/docs/assets/hello-world/panel-sizes.png differ diff --git a/docs/assets/hello-world/shots.json b/docs/assets/hello-world/shots.json index 471dcd67..3828725c 100644 --- a/docs/assets/hello-world/shots.json +++ b/docs/assets/hello-world/shots.json @@ -186,7 +186,7 @@ { "shot": "msg-long", "label": "\"Welcome to the workshop\"", - "sublabel": "too long: the plugin does not shrink or wrap, so the ends are clipped" + "sublabel": "too long: the font steps down until it fits, at the cost of legibility" } ] }, @@ -197,7 +197,7 @@ { "shot": "p-64", "label": "64 x 32", - "sublabel": "the default message no longer fits" + "sublabel": "the default message shrinks to fit, but barely reads" }, { "shot": "p-128", diff --git a/docs/assets/hello-world/show-time.png b/docs/assets/hello-world/show-time.png index 3d8e3592..1a47788d 100644 Binary files a/docs/assets/hello-world/show-time.png and b/docs/assets/hello-world/show-time.png differ diff --git a/docs/assets/ledmatrix-stocks/display-mode.png b/docs/assets/ledmatrix-stocks/display-mode.png index 3e800fc2..a7f642fe 100644 Binary files a/docs/assets/ledmatrix-stocks/display-mode.png and b/docs/assets/ledmatrix-stocks/display-mode.png differ diff --git a/docs/assets/ledmatrix-stocks/gain-loss.png b/docs/assets/ledmatrix-stocks/gain-loss.png index 0b995880..ff14860b 100644 Binary files a/docs/assets/ledmatrix-stocks/gain-loss.png and b/docs/assets/ledmatrix-stocks/gain-loss.png differ diff --git a/docs/assets/ledmatrix-stocks/hero.png b/docs/assets/ledmatrix-stocks/hero.png index 3db0e895..1262ed2a 100644 Binary files a/docs/assets/ledmatrix-stocks/hero.png and b/docs/assets/ledmatrix-stocks/hero.png differ diff --git a/docs/assets/ledmatrix-stocks/panel-sizes.png b/docs/assets/ledmatrix-stocks/panel-sizes.png index 4afab696..09ca7ce0 100644 Binary files a/docs/assets/ledmatrix-stocks/panel-sizes.png and b/docs/assets/ledmatrix-stocks/panel-sizes.png differ diff --git a/docs/assets/ledmatrix-stocks/toggle-chart.png b/docs/assets/ledmatrix-stocks/toggle-chart.png index 71c159d7..5b8ab1b9 100644 Binary files a/docs/assets/ledmatrix-stocks/toggle-chart.png and b/docs/assets/ledmatrix-stocks/toggle-chart.png differ diff --git a/docs/plugin-development/01-plugin-anatomy.md b/docs/plugin-development/01-plugin-anatomy.md index 364f1e3b..ee2718ad 100644 --- a/docs/plugin-development/01-plugin-anatomy.md +++ b/docs/plugin-development/01-plugin-anatomy.md @@ -61,8 +61,9 @@ draw here. ## The lifecycle methods -The core calls six methods on your plugin. Only `__init__` is strictly -mandatory, but a useful plugin implements at least `update` and `display`. +The core calls six methods on your plugin. `update` and `display` are abstract +on `BasePlugin`, so every plugin must implement them (a class missing either +cannot be instantiated); the rest have working base implementations. | Method | Signature | When the core calls it | Rule | |--------|-----------|------------------------|------| @@ -110,8 +111,9 @@ def display(self, force_clear=False): if force_clear: self.display_manager.clear() w, h = self.display_manager.width, self.display_manager.height + # x is the left edge unless centered=True; y is the top of the text self.display_manager.draw_text(self.message, x=w // 2, y=h // 2, - color=self.color, font=self.bdf_font) + color=self.color, font=self.bdf_font, centered=True) self.display_manager.update_display() ``` @@ -219,7 +221,8 @@ class MyPlugin(BasePlugin): if force_clear: self.display_manager.clear() w, h = self.display_manager.width, self.display_manager.height - self.display_manager.draw_text(self.message, x=w // 2, y=h // 2, color=self.color) + self.display_manager.draw_text(self.message, x=w // 2, y=h // 2, + color=self.color, centered=True) self.display_manager.update_display() def validate_config(self): diff --git a/docs/plugin-development/02-core-api.md b/docs/plugin-development/02-core-api.md index 68b0a2a8..494b7e4f 100644 --- a/docs/plugin-development/02-core-api.md +++ b/docs/plugin-development/02-core-api.md @@ -15,10 +15,13 @@ what each provides. | `self.cache_manager` | Shared network/data cache — use it for anything fetched | | `self.plugin_manager` | Access to shared services: `font_manager`, `config_manager`, other plugins | -The base class also derives some convenience values from config that most plugins -read directly: `self.enabled` (default `True`), `self.display_duration`, -`self.update_interval`, and `self.global_config` (the `global` config block, -where cross-cutting settings like `target_fps` live). +The base class also sets `self.enabled` (from config, default `True`) and +exposes `self.global_config` — the **whole** LEDMatrix config, read-only, where +device-wide settings like `target_fps` and `timezone` live (`{}` when +unavailable). It does **not** set `self.display_duration` or +`self.update_interval`: read those from `self.config` yourself (the core asks +through `get_display_duration()` / `get_update_interval()`, which you can +override). ## `self.logger` @@ -59,7 +62,7 @@ The drawing surface. The most common members: | `.small_font` / `.extra_small_font` / `.regular_font` | Pre-loaded fonts you can use without registering your own | Scrolling plugins additionally coordinate with the core loop through -`.set_scrolling_state(...)`, `.is_currently_scrolling`, `.defer_update(...)`, and +`.set_scrolling_state(...)`, `.is_currently_scrolling()`, `.defer_update(...)`, and `.process_deferred_updates()` — see [topic 3](./03-advanced-features.md#high-fps--smooth-scrolling). Two rendering styles coexist: draw with `draw_text` for simple text (like @@ -77,9 +80,9 @@ and multiple plugins don't re-hit APIs. Fetch in `update()`, never in |--------|---------| | `get(key, max_age=)` | Return the cached value, or `None` if missing/older than `max_age` | | `set(key, value, ttl=)` | Store a value with an optional time-to-live | -| `get_cached_data_with_strategy(key, strategy)` | Strategy-driven read (e.g. `'leaderboard'`) that layers a TTL/refresh policy on top of raw get/set | +| `get_cached_data_with_strategy(key, data_type)` | Strategy-driven read (e.g. `'leaderboard'`) that layers a TTL/refresh policy on top of raw get/set | | `save_cache(key, data)` | Strategy-driven write partner to the above | -| `get_with_auto_strategy(...)` | Auto-selected strategy variant | +| `get_with_auto_strategy(key)` | Same, with the data type inferred from the key | | `delete(key)` / `clear_cache()` | Invalidate one key / everything | **Always namespace keys with your plugin id** so they never collide with another @@ -137,10 +140,15 @@ fm.register_manager_font( **Fetch in `display()`:** ```python -message_font = fm.get_font(f"{self.plugin_id}.message") +message_font = fm.resolve_font(f"{self.plugin_id}.message", "press_start", 10, + plugin_id=self.plugin_id) self.display_manager.draw_text(self.message, x=..., y=..., font=message_font) ``` +`resolve_font(element_key, family, size_px, plugin_id=None)` applies any user +override for that element, then loads the font; `get_font(family, size_px)` +takes no element key and skips overrides. + The `element_key` convention is `f"{self.plugin_id}."` — prefixing with the plugin id avoids collisions with other plugins' registered fonts. Common families seen across plugins include `press_start` and `four_by_six`. Provide a diff --git a/docs/plugin-development/03-advanced-features.md b/docs/plugin-development/03-advanced-features.md index 64740256..15e30f60 100644 --- a/docs/plugin-development/03-advanced-features.md +++ b/docs/plugin-development/03-advanced-features.md @@ -18,18 +18,19 @@ so your plugin still loads on an older core. By default the core shows a plugin for a fixed `display_duration`. Dynamic duration lets a plugin say "hold my screen for a computed time instead" — long -enough to finish a scroll, or to keep a live game up. You opt in by implementing -`supports_dynamic_duration()`; the rest of the family lets the core size and end -the turn. +enough to finish a scroll, or to keep a live game up. The core asks +`supports_dynamic_duration()`; `BasePlugin`'s version returns the config's +`dynamic_duration.enabled`, so override it only for custom logic. The rest of the +family lets the core size and end the turn. | Hook | Signature | Role | |------|-----------|------| | `supports_dynamic_duration` | `(self) -> bool` (or `(self, mode_type=None)`) | Gate — return `True` to opt in | | `get_display_duration` | `(self) -> float` | The computed seconds to display | -| `get_cycle_duration` | `(self, display_mode=None) -> Optional[float]` | Per-mode duration for one full cycle | -| `get_dynamic_duration` | `(self) -> int` | Simple computed duration (alt to the above) | -| `get_dynamic_duration_cap` | `(self) -> Optional[float]` | Upper bound the core will wait | -| `get_dynamic_duration_floor` | `(self) -> Optional[float]` | Lower bound | +| `get_cycle_duration` | `(self, display_mode=None) -> Optional[float]` (called with `display_mode=` as a keyword) | Per-mode duration for one full cycle | +| `get_dynamic_duration` | `(self) -> int` | Plugin-internal helper some plugins define — the core does not call it | +| `get_dynamic_duration_cap` | `(self) -> Optional[float]` | Upper bound the core will wait (base reads `dynamic_duration.max_duration_seconds`) | +| `get_dynamic_duration_floor` | `(self) -> Optional[float]` | Plugin-internal helper — the core does not call it | | `is_cycle_complete` | `(self) -> bool` | Tell the core the scroll/animation finished | | `reset_cycle_state` | `(self) -> None` | Reset between turns (call `super()` if you override) | @@ -105,6 +106,13 @@ There are three mechanisms, used together: [`plugins/christmas-countdown/config_schema.json`](../../plugins/christmas-countdown/config_schema.json) switches 120 FPS transitions vs. 30 FPS. +None of this matters unless the core runs your plugin in its high-FPS loop +(8 ms ticks). It does that only for a plugin that declares `needs_high_fps` +(an attribute or property, read when the mode starts) or has a truthy +`enable_scrolling`; every other plugin gets **one `display()` call per +second**, so a sub-second animation aliases. Declare `needs_high_fps = True` +when the screen genuinely moves. + Scrolling plugins also coordinate with the loop through the display manager's `set_scrolling_state`, `defer_update`, and `process_deferred_updates` so the core knows a scroll is in progress. @@ -143,19 +151,23 @@ endlessly-scrolling strip. A plugin opts in by implementing: | Hook | Signature | Role | |------|-----------|------| -| `get_vegas_content` | `(self) -> Optional[list[Image]]` | The PIL image(s) to splice into the strip, or `None` | -| `get_vegas_content_type` | `(self) -> str` | `'single'` or `'multi'` (multiple scrollable items, e.g. games) | +| `get_vegas_content` | `(self) -> Optional[Image \| list[Image]]` | The PIL image(s) to splice into the strip, or `None` | +| `get_vegas_content_type` | `(self) -> str` | `'multi'` (multiple scrollable items, e.g. games), `'static'` (one block; the base default) or `'none'` (excluded) | | `get_vegas_display_mode` | `(self) -> VegasDisplayMode` | How this plugin behaves in the strip | Import the enum defensively — older cores don't ship it: ```python +from src.plugin_system.base_plugin import BasePlugin try: - from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode + from src.plugin_system.base_plugin import VegasDisplayMode except ImportError: VegasDisplayMode = None ``` +(Keep `BasePlugin` out of the `try` — a combined import that fails leaves +`BasePlugin` undefined too.) + The `vegas_mode` config key (mark it `x-advanced`) overrides the display mode and is an enum: @@ -163,6 +175,9 @@ is an enum: - `fixed` — the whole display scrolls by as one block - `static` — the marquee pauses while the plugin shows for its duration +Only `static` changes what Vegas does: `scroll` and `fixed` both join the strip +(Vegas never told them apart, and core 3.9.0 drops the distinction). + `get_vegas_display_mode` should honor the config override, falling back to the plugin's natural default: @@ -186,7 +201,8 @@ and the `vegas_mode` config declarations in and [`plugins/olympics/config_schema.json`](../../plugins/olympics/config_schema.json). Core 3.8.0 adds `vegas_participation` (`"scroll"`, `"pause"` or `"exclude"`) in the manifest as the way to declare a fixed answer; the hooks above still -decide it when the manifest and the user's config say nothing, and only +decide it when the manifest and the user's config say nothing (override +`get_vegas_participation()` only when the answer depends on state), and only `STATIC` (pauses) and content type `'none'` (excluded) change anything. Do not implement `get_supported_vegas_modes`: core never read it and removes it in 3.9.0. See the core's `docs/PLUGIN_API_REFERENCE.md`, "Vegas participation". diff --git a/docs/plugin-development/04-styling-and-skins.md b/docs/plugin-development/04-styling-and-skins.md index cbc7532a..522bf31d 100644 --- a/docs/plugin-development/04-styling-and-skins.md +++ b/docs/plugin-development/04-styling-and-skins.md @@ -15,7 +15,7 @@ extensions that shape the config form itself. The older, explicit form: a `customization` object in `config_schema.json` with one sub-object per text element, each declaring `font`, `font_size`, and -`text_color`. About 17 plugins use a `customization` block. The canonical +`text_color`. About two dozen plugins use a `customization` block. The canonical per-element shape (from [`plugins/clock-simple/config_schema.json`](../../plugins/clock-simple/config_schema.json), element `time_text`): @@ -36,15 +36,18 @@ element `time_text`): Your code then reads `config["customization"]["time_text"]["font_size"]` etc. and feeds those into font registration / drawing. This is fully self-contained — it -works on any core. +works on any core. (Core 3.4.0+ also recognises a hand-written block of this shape +and renders it with its composite style editor, with no plugin change.) ### B) The `x-style-elements` shorthand (newer, core-assisted) The compact form: instead of hand-writing each element object, you declare a single `x-style-elements` map on the `customization` object, and a **newer core** -expands it into the full per-element style UI and a resolver. Only -[`plugins/of-the-day`](../../plugins/of-the-day/config_schema.json) uses it today -— it's the reference implementation. +expands it into the full per-element style UI and a resolver. +[`plugins/of-the-day`](../../plugins/of-the-day/config_schema.json) is the +reference implementation; `ledmatrix-weather` uses it too. A sibling +`x-style-modes` declaration adds per-display-mode overrides +(`customization.modes.`); the scoreboards use it. ```json "customization": { @@ -107,6 +110,13 @@ title_y = margin_top + title.offset[1] self.display_manager.draw_text(text, x=title_x, y=title_y, color=title.color, font=title.font) ``` +On core 3.4.0+ you don't need to build the resolver yourself: `BasePlugin.styles` +finds the plugin's schema, rebuilds on config change, and never raises +(`self.styles.style("title_text", classic_font=..., classic_size=..., +classic_color=...)`; set the class attribute `STYLE_MODE` for per-mode +overrides). Guard it with `getattr(self, "styles", None)` if your floor is +older. + See [`plugins/of-the-day/manager.py`](../../plugins/of-the-day/manager.py) for the full `_element_styles()` helper, including the `STYLE_AVAILABLE == False` fallback that loads bundled fonts directly with offset `(0, 0)`. @@ -137,13 +147,14 @@ Beyond styling, the web UI honors a family of custom `x-` keys in | `x-columns` | array | Column keys for the `array-table` widget | | `x-placeholder` | string | Input placeholder text | | `x-display` | string (e.g. `"hidden"`) | Field display mode | +| `x-style-elements` / `x-style-modes` | object / array | Compact style declarations on `customization` (see above) | ### `x-widget` values seen in the wild `color-picker` (the common one), `checkbox-group`, `file-upload` / `file-upload-single`, `array-table`, `radio`, `select`, `time-picker`, `date-picker`, `schedule`, `tag-input`, `custom-feeds`, `plugin-file-manager`, -`google-calendar-picker`. +`google-calendar-picker`, `google-oauth`. Examples: `color-picker` in [`plugins/baseball-scoreboard/config_schema.json`](../../plugins/baseball-scoreboard/config_schema.json); diff --git a/docs/plugin-development/05-adaptive-layout.md b/docs/plugin-development/05-adaptive-layout.md index 48d3eb0f..4139bae7 100644 --- a/docs/plugin-development/05-adaptive-layout.md +++ b/docs/plugin-development/05-adaptive-layout.md @@ -137,8 +137,9 @@ vs. "caller should draw its classic layout," so there's always a working path. - **flights `layout` / `widescreen_threshold`** — flights has its own compact/wide selection independent of `layout_mode`. - **`design_size` in the manifest** — declare your layout's reference size (e.g. - `"display": {"design_size": [128, 32]}`) so the harness and adaptive scaling - know the baseline. Opt into strict fill checking via + `"display": {"design_size": {"width": 128, "height": 32}}` — an object, per + the core manifest schema; the default is 128×32) so the harness and adaptive + scaling know the baseline. Opt into strict fill checking via `test/harness.json` `{"fill_check": "strict"}` once your plugin is adaptive. > The core's own `docs/ADAPTIVE_LAYOUT.md` is the authoritative reference for the diff --git a/docs/plugin-development/06-manifest-and-config-schema.md b/docs/plugin-development/06-manifest-and-config-schema.md index 5b2969f9..1557b7bd 100644 --- a/docs/plugin-development/06-manifest-and-config-schema.md +++ b/docs/plugin-development/06-manifest-and-config-schema.md @@ -14,25 +14,32 @@ Validated on every PR against the core repo's `schema/manifest_schema.json`. ### Core required fields +The core schema's `required` list — a manifest missing any of these fails CI: + | Field | Meaning | |-------|---------| | `id` | Plugin identifier — **must match the directory name** | | `name` | Human-readable display name | | `version` | Semver string, e.g. `"1.2.3"` | +| `author` | Plugin author | +| `entry_point` | Python file with the class (every plugin here uses `manager.py`; the loader falls back to it when absent, but the schema requires the field) | | `class_name` | The Python class name in the entry point | +| `compatible_versions` | Array of core-version constraint strings, e.g. `[">=3.8.0"]` — keep it in line with the floor below | + +Not in the schema's `required` list, but needed in practice: + +| Field | Meaning | +|-------|---------| | `display_modes` | Array of the mode names this plugin supports | ### Fields present on essentially every plugin -`author`, `description`, `entry_point` (always `manager.py`), `tags`, `versions` -(the changelog array), `last_updated`, and `compatible_versions` (an array of -constraint strings — almost all use `[">=2.0.0"]`). +`description`, `tags`, `versions` (the changelog array) and `last_updated`. ### Common optional fields | Field | Meaning | |-------|---------| -| `entry_point` | Python file with the class (default `manager.py`) | | `category` | Store category | | `config_schema` | Path to the schema file (the string `"config_schema.json"`) | | `icon`, `homepage`, `license` | Store display / links | @@ -68,8 +75,9 @@ usually a minimum-core-version key and a description: > > Note the **name is inverted** between the two locations — `min_ledmatrix_version` > at the top level, `ledmatrix_min_version` inside `versions[]` — which is easy to -> read past. Four plugins declare the top-level form today (`ledmatrix-flights`, -> `ledmatrix-leaderboard`, `ledmatrix-music`, `ledmatrix-stocks`), and for those +> read past. Five plugins declare the top-level form today (`ledmatrix-flights`, +> `ledmatrix-leaderboard`, `ledmatrix-music`, `ledmatrix-stocks`, +> `nfl-stat-leaders`), and for those > **editing `versions[0]` changes nothing the core reads.** Check for a top-level > key before raising a floor, and raise the one that actually wins. CI fails a > changed plugin whose floors in these places disagree, so raise them together. @@ -120,7 +128,9 @@ never receive the update — and CI fails the PR. ### Every plugin change: 1. Make your code changes in `plugins//`. -2. **Bump `version`** in `plugins//manifest.json` (semver). +2. **Bump `version`** in `plugins//manifest.json` (semver) and add + a matching new entry at the **top** of `versions[]` — CI requires + `version == versions[0].version` and a new top entry. 3. Commit — the pre-commit hook runs `update_registry.py` and stages the updated `plugins.json` into the same commit. diff --git a/docs/plugin-development/07-testing-ci-and-registry.md b/docs/plugin-development/07-testing-ci-and-registry.md index 7a3831af..4bb8e5f4 100644 --- a/docs/plugin-development/07-testing-ci-and-registry.md +++ b/docs/plugin-development/07-testing-ci-and-registry.md @@ -112,7 +112,7 @@ Four workflows run from `.github/workflows/`: ### `test-plugins.yml` — "Plugin Safety" Triggers on PRs touching `plugins/**`, `scripts/**`, `update_registry.py`, -`plugins.json` or the workflow itself. It checks out the core repo +`plugins.json`, the root `README.md` or the workflow itself. It checks out the core repo (`ChuckBuilds/LEDMatrix@main`, full history with tags) for the harness, the manifest schema and the imports several guards need. @@ -145,6 +145,9 @@ Steps, in order: 6. **Repo-level guard tests** (always) — every `scripts/test_*.py`, discovered by glob, with `LEDMATRIX_CORE` and `PYTHONPATH` pointing at the core checkout. A new guard runs the day it lands; there is no list to update. + This is where `test_readme_lists_every_plugin.py` runs: every + `plugins//` has a row in the root README's Available Plugins tables, and + each `### Category (N)` count matches its rows. 7. **Plugin unit tests** (always, for plugins with any change) — `scripts/run_plugin_tests.py --core ` runs the plugins' own `test_*.py`. @@ -169,9 +172,6 @@ the core. - `test_pixel_perfect_text.py`: text draws are 1-bit (no anti-aliasing) - `update_readme_previews.py --check`: the root README's Preview column matches the `hero.png` files on disk -- `test_readme_lists_every_plugin.py`: every `plugins//` has a row in the - root README's Available Plugins tables, and each `### Category (N)` count - matches its rows - `check_secrets_template.py`: every `x-secret` schema field has a placeholder in the root `config_secrets.template.json`, under the plugin id @@ -315,7 +315,7 @@ Not run by CI; use them by hand. - `scripts/render_docs_assets.py` — renders a plugin README's screenshots from `docs/assets//shots.json` through the core renderer; `--check` diffs against what is committed. `--all --check` is not in CI: it re-renders all - 42 shot lists through the core renderer (each plugin's dependencies needed), + 44 shot lists through the core renderer (each plugin's dependencies needed), and a local run was still rendering, with no output yet, after more than five minutes. Run it for the plugins you touched, e.g. `--plugin --check`. diff --git a/docs/plugin-development/README.md b/docs/plugin-development/README.md index 09eb44ad..aa3945dc 100644 --- a/docs/plugin-development/README.md +++ b/docs/plugin-development/README.md @@ -25,6 +25,7 @@ back to. Then work through these topics in order: | 5 | [Adaptive layout](./05-adaptive-layout.md) | `layout_mode` classic vs. adaptive, and rendering across matrix sizes | | 6 | [Manifest & config schema](./06-manifest-and-config-schema.md) | Manifest fields, `config_schema.json` conventions, and the version workflow | | 7 | [Testing, CI & the registry](./07-testing-ci-and-registry.md) | Safety harness, module-collision checks, `plugins.json`, and the CI gates | +| 8 | [Shared sports code](./08-shared-sports-code.md) | The scoreboards' copied modules, their lineages, what core already ships, and porting a fix across siblings | ## The golden rules diff --git a/plugins.json b/plugins.json index 7ebcaef2..9ad8f6c4 100644 --- a/plugins.json +++ b/plugins.json @@ -24,12 +24,11 @@ "plugin_path": "plugins/cricket-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.3.2", - "ledmatrix_min_version": "2.0.0", - "commit": "e429d8e46b547f09a0bd93af60002db6357a8bf8" + "latest_version": "1.3.3", + "ledmatrix_min_version": "2.0.0" }, { "id": "7-segment-clock", @@ -49,12 +48,11 @@ "plugin_path": "plugins/7-segment-clock", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.0.10", - "ledmatrix_min_version": "2.0.0", - "commit": "62fa95853476715b833abfe0b207d4f50876b6b4" + "latest_version": "1.0.11", + "ledmatrix_min_version": "2.0.0" }, { "id": "baseball-scoreboard", @@ -80,9 +78,8 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.56.1", - "ledmatrix_min_version": "3.8.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "latest_version": "1.57.0", + "ledmatrix_min_version": "3.8.0" }, { "id": "basketball-scoreboard", @@ -107,9 +104,8 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.41.2", - "ledmatrix_min_version": "3.8.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "latest_version": "1.42.0", + "ledmatrix_min_version": "3.8.0" }, { "id": "calendar", @@ -132,9 +128,8 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.2.10", - "ledmatrix_min_version": "3.3.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "latest_version": "1.2.11", + "ledmatrix_min_version": "3.3.0" }, { "id": "christmas-countdown", @@ -153,12 +148,11 @@ "plugin_path": "plugins/christmas-countdown", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "2.0.3", - "ledmatrix_min_version": "2.0.0", - "commit": "d6223d0f6f6299238fa6c952143004a2f45e3c92" + "latest_version": "2.0.4", + "ledmatrix_min_version": "2.0.0" }, { "id": "clock-simple", @@ -176,12 +170,11 @@ "plugin_path": "plugins/clock-simple", "stars": 0, "downloads": 0, - "last_updated": "2026-09-29", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.1.3", - "ledmatrix_min_version": "2.0.0", - "commit": "843588025a81197056f8d96779ccb2be19337ab8" + "latest_version": "1.1.4", + "ledmatrix_min_version": "2.0.0" }, { "id": "countdown", @@ -204,10 +197,9 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "3.3.4", + "latest_version": "3.3.5", "icon": "fa-clock", - "ledmatrix_min_version": "2.0.0", - "commit": "a6a38144d3544b236032926bf844ad730c825917" + "ledmatrix_min_version": "2.0.0" }, { "id": "f1-scoreboard", @@ -228,12 +220,11 @@ "plugin_path": "plugins/f1-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-09-29", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.10.0", - "ledmatrix_min_version": "3.6.1", - "commit": "30455671d5856814cc972040297341de60b673fa" + "latest_version": "1.10.1", + "ledmatrix_min_version": "3.6.1" }, { "id": "f1-live", @@ -283,9 +274,8 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "3.18.4", - "ledmatrix_min_version": "3.8.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "latest_version": "3.18.5", + "ledmatrix_min_version": "3.8.0" }, { "id": "geochron", @@ -356,12 +346,11 @@ "plugin_path": "plugins/hello-world", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.1.3", - "ledmatrix_min_version": "2.0.0", - "commit": "d6223d0f6f6299238fa6c952143004a2f45e3c92" + "latest_version": "1.1.4", + "ledmatrix_min_version": "2.0.0" }, { "id": "hockey-scoreboard", @@ -385,10 +374,9 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.42.1", + "latest_version": "1.42.2", "icon": "fas fa-hockey-puck", - "ledmatrix_min_version": "3.8.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "ledmatrix_min_version": "3.8.0" }, { "id": "lacrosse-scoreboard", @@ -411,10 +399,9 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.36.2", + "latest_version": "1.36.3", "icon": "fas fa-baseball-ball", - "ledmatrix_min_version": "3.8.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "ledmatrix_min_version": "3.8.0" }, { "id": "birdnet-go", @@ -436,12 +423,11 @@ "plugin_path": "plugins/birdnet-go", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.2.7", - "ledmatrix_min_version": "2.0.0", - "commit": "d6223d0f6f6299238fa6c952143004a2f45e3c92" + "latest_version": "1.2.8", + "ledmatrix_min_version": "2.0.0" }, { "id": "leaderboard", @@ -465,15 +451,14 @@ "plugin_path": "plugins/ledmatrix-leaderboard", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.5.4", + "latest_version": "1.5.5", "ledmatrix_min_version": "3.4.0", "aliases": [ "ledmatrix-leaderboard" - ], - "commit": "62fa95853476715b833abfe0b207d4f50876b6b4" + ] }, { "id": "ledmatrix-flights", @@ -523,12 +508,11 @@ "plugin_path": "plugins/march-madness", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "2.1.3", - "ledmatrix_min_version": "3.4.0", - "commit": "e429d8e46b547f09a0bd93af60002db6357a8bf8" + "latest_version": "2.1.4", + "ledmatrix_min_version": "3.4.0" }, { "id": "masters-tournament", @@ -549,12 +533,11 @@ "plugin_path": "plugins/masters-tournament", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "3.2.4", - "ledmatrix_min_version": "3.4.0", - "commit": "62fa95853476715b833abfe0b207d4f50876b6b4" + "latest_version": "3.2.5", + "ledmatrix_min_version": "3.4.0" }, { "id": "mqtt-notifications", @@ -574,12 +557,11 @@ "plugin_path": "plugins/mqtt-notifications", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.2.7", - "ledmatrix_min_version": "2.0.0", - "commit": "67d6ae4b1f4bc7529cc2fcc485d9f1477e9dea66" + "latest_version": "1.2.8", + "ledmatrix_min_version": "2.0.0" }, { "id": "music", @@ -602,12 +584,11 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.5.3", + "latest_version": "1.5.4", "ledmatrix_min_version": "2.0.0", "aliases": [ "ledmatrix-music" - ], - "commit": "988f1a9e5c4d037a81ae4ec4bfc7b0aec3fcda21" + ] }, { "id": "news", @@ -628,12 +609,11 @@ "plugin_path": "plugins/news", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.6.4", - "ledmatrix_min_version": "3.4.0", - "commit": "026931a925cddb96c214eb92fe2796f5a33a4b89" + "latest_version": "1.6.5", + "ledmatrix_min_version": "3.4.0" }, { "id": "on-air", @@ -654,13 +634,12 @@ "plugin_path": "plugins/on-air", "stars": 0, "downloads": 0, - "last_updated": "2026-09-29", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.2.13", + "latest_version": "1.2.14", "icon": "fa-circle-dot", - "ledmatrix_min_version": "2.0.0", - "commit": "c695b9761d59a9379222c8abfca41c14297e6cb9" + "ledmatrix_min_version": "2.0.0" }, { "id": "nfl-draft", @@ -680,13 +659,12 @@ "plugin_path": "plugins/nfl-draft", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "2.2.1", + "latest_version": "2.2.2", "icon": "fas fa-football-ball", - "ledmatrix_min_version": "3.4.0", - "commit": "62fa95853476715b833abfe0b207d4f50876b6b4" + "ledmatrix_min_version": "3.4.0" }, { "id": "odds-ticker", @@ -710,12 +688,11 @@ "plugin_path": "plugins/odds-ticker", "stars": 0, "downloads": 0, - "last_updated": "2026-10-01", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.7.6", - "ledmatrix_min_version": "3.4.0", - "commit": "e90b592502f3a20e50fe62a4ebc977d9c24a2d7d" + "latest_version": "1.7.7", + "ledmatrix_min_version": "3.4.0" }, { "id": "of-the-day", @@ -735,12 +712,11 @@ "plugin_path": "plugins/of-the-day", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.4.8", - "ledmatrix_min_version": "2.0.0", - "commit": "e429d8e46b547f09a0bd93af60002db6357a8bf8" + "latest_version": "1.4.9", + "ledmatrix_min_version": "2.0.0" }, { "id": "olympics", @@ -765,9 +741,8 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "3.1.4", - "ledmatrix_min_version": "2.0.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "latest_version": "3.1.5", + "ledmatrix_min_version": "2.0.0" }, { "id": "pga-tour-leaderboard", @@ -844,12 +819,11 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-01", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "2.39.1", - "ledmatrix_min_version": "3.8.0", - "commit": "56e3f7a0070a26bd7b3fcd80b0214445ded480ac" + "latest_version": "2.39.2", + "ledmatrix_min_version": "3.8.0" }, { "id": "static-image", @@ -869,12 +843,11 @@ "plugin_path": "plugins/static-image", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.1.7", - "ledmatrix_min_version": "2.0.0", - "commit": "62fa95853476715b833abfe0b207d4f50876b6b4" + "latest_version": "1.1.8", + "ledmatrix_min_version": "2.0.0" }, { "id": "stock-news", @@ -897,12 +870,11 @@ "plugin_path": "plugins/stock-news", "stars": 0, "downloads": 0, - "last_updated": "2026-10-01", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "2.8.3", - "ledmatrix_min_version": "3.4.0", - "commit": "9826f800aa163713d7927975bb1ddaf0de121751" + "latest_version": "2.8.4", + "ledmatrix_min_version": "3.4.0" }, { "id": "stocks", @@ -922,16 +894,15 @@ "plugin_path": "plugins/ledmatrix-stocks", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "2.11.2", + "latest_version": "2.11.3", "icon": "fas fa-chart-line", "ledmatrix_min_version": "3.4.0", "aliases": [ "ledmatrix-stocks" - ], - "commit": "026931a925cddb96c214eb92fe2796f5a33a4b89" + ] }, { "id": "text-display", @@ -951,12 +922,11 @@ "plugin_path": "plugins/text-display", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.3.2", - "ledmatrix_min_version": "3.4.0", - "commit": "d6223d0f6f6299238fa6c952143004a2f45e3c92" + "latest_version": "1.3.3", + "ledmatrix_min_version": "3.4.0" }, { "id": "tide-display", @@ -977,13 +947,12 @@ "plugin_path": "plugins/tide-display", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.3.4", + "latest_version": "1.3.5", "icon": "fa-water", - "ledmatrix_min_version": "2.0.0", - "commit": "62fa95853476715b833abfe0b207d4f50876b6b4" + "ledmatrix_min_version": "2.0.0" }, { "id": "ufc-scoreboard", @@ -1007,10 +976,9 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.19.2", + "latest_version": "1.19.3", "icon": "fas fa-fist-raised", - "ledmatrix_min_version": "3.8.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "ledmatrix_min_version": "3.8.0" }, { "id": "weather", @@ -1032,17 +1000,16 @@ "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", "branch": "main", "plugin_path": "plugins/ledmatrix-weather", - "latest_version": "2.8.0", + "latest_version": "2.8.1", "stars": 0, "downloads": 0, - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", "ledmatrix_min_version": "3.8.0", "aliases": [ "ledmatrix-weather" - ], - "commit": "555e63f5c79221d206d740bea11698249ce0eb08" + ] }, { "id": "web-ui-info", @@ -1086,12 +1053,11 @@ "plugin_path": "plugins/youtube-stats", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.3.2", - "ledmatrix_min_version": "3.4.0", - "commit": "44618443dd6e2ea581377d589864dcc9190d2114" + "latest_version": "1.3.3", + "ledmatrix_min_version": "3.4.0" }, { "id": "ledmatrix-elections", @@ -1111,12 +1077,11 @@ "plugin_path": "plugins/ledmatrix-elections", "stars": 0, "downloads": 0, - "last_updated": "2026-09-16", + "last_updated": "2026-10-02", "verified": false, "screenshot": "", - "latest_version": "1.3.1", - "ledmatrix_min_version": "3.4.0", - "commit": "912724bccbf6c56985be2b1fcdf16b79a33dd0c2" + "latest_version": "1.3.2", + "ledmatrix_min_version": "3.4.0" }, { "id": "ledmatrix-dresden-departures", @@ -1164,10 +1129,9 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.35.1", - "last_updated": "2026-10-01", - "ledmatrix_min_version": "3.8.0", - "commit": "56e3f7a0070a26bd7b3fcd80b0214445ded480ac" + "latest_version": "1.35.2", + "last_updated": "2026-10-02", + "ledmatrix_min_version": "3.8.0" }, { "id": "tidbyt-baseball-scoreboard", @@ -1210,12 +1174,11 @@ "plugin_path": "plugins/nrl-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-01", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.34.1", - "ledmatrix_min_version": "3.8.0", - "commit": "56e3f7a0070a26bd7b3fcd80b0214445ded480ac" + "latest_version": "1.34.2", + "ledmatrix_min_version": "3.8.0" }, { "id": "jellyfin-now-playing", @@ -1236,12 +1199,11 @@ "plugin_path": "plugins/jellyfin-now-playing", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.3.2", - "ledmatrix_min_version": "3.4.0", - "commit": "d6223d0f6f6299238fa6c952143004a2f45e3c92" + "latest_version": "1.3.3", + "ledmatrix_min_version": "3.4.0" }, { "id": "incoming-packages", @@ -1262,13 +1224,12 @@ "plugin_path": "plugins/incoming-packages", "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins/incoming-packages/assets/screenshot.png", - "latest_version": "1.1.3", + "latest_version": "1.1.4", "icon": "fas fa-box", - "ledmatrix_min_version": "3.4.0", - "commit": "8eb980951dedda974ebca5d9eac76ca4e7aa7601" + "ledmatrix_min_version": "3.4.0" }, { "id": "pomodoro-timer", @@ -1289,13 +1250,12 @@ "plugin_path": "plugins/pomodoro-timer", "stars": 0, "downloads": 0, - "last_updated": "2026-09-29", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.3.9", + "latest_version": "1.3.10", "icon": "fa-hourglass-half", - "ledmatrix_min_version": "2.0.0", - "commit": "843588025a81197056f8d96779ccb2be19337ab8" + "ledmatrix_min_version": "2.0.0" }, { "id": "sleeper-fantasy", @@ -1343,10 +1303,9 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins/blackjack/assets/hero.png", - "latest_version": "1.3.1", + "latest_version": "1.3.2", "icon": "fa-diamond", - "ledmatrix_min_version": "3.2.0", - "commit": "f9ced936bab32e05df5dd71bcabbc94972aa634b" + "ledmatrix_min_version": "3.2.0" }, { "id": "ledmatrix-nascar", diff --git a/plugins/7-segment-clock/README.md b/plugins/7-segment-clock/README.md index 913bc151..3dfdf808 100644 --- a/plugins/7-segment-clock/README.md +++ b/plugins/7-segment-clock/README.md @@ -227,12 +227,12 @@ the clock renders at 1.8× actually leaves a 3-pixel gap. panel, plus spacing 2 and 10 on a 64x32 panel](../../docs/assets/7-segment-clock/digit-spacing.png) -**One caveat worth knowing.** The auto-scaler sizes the digits from the width -of the digits alone — it does not account for the spacing you add on top. On a -wide panel that is harmless. On a narrow one it is not: as the bottom row above -shows, `digit_spacing: 10` on a 64×32 panel pushes the outer digits off both -edges. If you are on a 64-wide panel, keep `digit_spacing` at 4 or below, or -turn off the leading zero to buy back a digit's width. +The auto-scaler counts the spacing as part of the width it fits to the panel, +so no value in the 0–10 range pushes digits off the edge. On a narrow panel a +large spacing is paid for in digit size instead: `digit_spacing: 10` on a +64×32 panel shrinks the digits to about 0.6× so the whole string still fits. +If you are on a 64-wide panel and want the digits as large as possible, keep +`digit_spacing` low, or turn off the leading zero to buy back a digit's width. ### `color` @@ -267,7 +267,7 @@ white. The clock has no fixed size. On every frame it computes a scale factor: ```text -scale = min( (panel_width * 0.9) / total_digit_width, +scale = min( (panel_width * 0.9) / (total_digit_width + total_spacing), (panel_height * 0.9) / 32 ) clamped to the range 0.5 – 3.0 @@ -316,9 +316,10 @@ means UTC. That is the Pi's system clock, not the plugin. Check `timedatectl` and that NTP is reaching a time server. -**The outer digits are cut off.** -See the caveat under [`digit_spacing`](#digit_spacing) — reduce the spacing, or -move to a wider panel. +**The digits look small.** +A large [`digit_spacing`](#digit_spacing) or a leading zero takes width the +auto-scaler would otherwise give the digits. Reduce the spacing, or turn off +`has_leading_zero`. **The colon is missing.** If it is missing only some of the time, that is @@ -338,6 +339,8 @@ is missing permanently, check that `assets/images/separator.png` exists. ├── config_schema.json # Settings schema; source of truth for defaults ├── requirements.txt # pytz ├── test_render_polarity.py # Regression test for digit rendering +├── test_frame_cadence.py # Colon blink and per-minute repaint at the core's frame rate +├── test/ # Safety-harness config and golden images ├── README.md ├── LICENSE └── assets/ diff --git a/plugins/7-segment-clock/manifest.json b/plugins/7-segment-clock/manifest.json index 0d24bdb3..614a7fe1 100644 --- a/plugins/7-segment-clock/manifest.json +++ b/plugins/7-segment-clock/manifest.json @@ -1,7 +1,7 @@ { "id": "7-segment-clock", "name": "7-Segment Clock", - "version": "1.0.10", + "version": "1.0.11", "description": "Display a retro-style 7-segment clock with customizable colors", "author": "LEDMatrix", "entry_point": "manager.py", @@ -24,6 +24,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.0.11", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "README only: the digit_spacing caveat described a bug fixed earlier. The auto-scaler counts the spacing in the width it fits, so no spacing pushes digits off a 64-wide panel; a large spacing shrinks the digits instead. The scale formula and troubleshooting entry now say so, and the file list includes test_frame_cadence.py and test/." + }, { "version": "1.0.10", "released": "2026-09-28", @@ -88,5 +94,5 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28" + "last_updated": "2026-10-02" } diff --git a/plugins/afl-scoreboard/CHANGELOG.md b/plugins/afl-scoreboard/CHANGELOG.md index 4a31794a..b0ca39f0 100644 --- a/plugins/afl-scoreboard/CHANGELOG.md +++ b/plugins/afl-scoreboard/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.35.2] - 2026-10-02 + +### Documentation +- README: `scroll_settings.scroll_speed` is documented with its real default, + `50` px/s (it said `1.0` "pixels per step"). +- README: documents `odds_update_interval` (`3600` s), + `live_odds_update_interval` (`60` s), the full-screen + `scroll_card.switch_show_date` / `switch_show_time` switches, + `customization.odds_text`, and the nested `game_limits` / + `display_options` copies and which one wins. No change in behaviour. + ## [1.35.1] - 2026-10-01 ### Changed diff --git a/plugins/afl-scoreboard/README.md b/plugins/afl-scoreboard/README.md index 53e6076b..f0d1ef54 100644 --- a/plugins/afl-scoreboard/README.md +++ b/plugins/afl-scoreboard/README.md @@ -358,6 +358,8 @@ also driving a panel, and raising the polling rate rarely helps. | `recent_update_interval` | `3600` | Fetch interval for the recent screen | | `upcoming_update_interval` | `3600` | Fetch interval for the upcoming screen | | `stale_game_timeout` | `300` | How long a live game may go without an update before it is dropped from the rotation. Guards against a game the API stops reporting sitting on the board forever | +| `odds_update_interval` | `3600` | How long fetched betting odds for a recent or upcoming game are reused before asking again (60–86400 s). Only matters with `show_odds` on | +| `live_odds_update_interval` | `60` | The same for a live game (30–3600 s) | | `no_data_interval_seconds` | `300` | How long to wait between live checks when nothing is on. Backs off further the longer nothing is found | | `live_idle_max_interval_seconds` | `900` | Ceiling for that back-off. Raise it out of season; lower it to notice the first game of the night sooner | | `schedule_lookback_days` | `14` | How far back the recent screen can see | @@ -383,6 +385,7 @@ also driving a panel, and raising the polling rate rarely helps. | `show_records` | `false` | **Advanced.** Draw each team's season record in the bottom corners | | `show_ranking` | `false` | **Advanced.** Draw a rank badge. AFL publishes no poll, so this shows nothing. **Hidden from the config form since 1.30.1 (still declared) — the empty rank badge also replaced the record.** | | `show_odds` | `true` | Draw the betting line. **ESPN publishes no odds for AFL** — see [Known Limitations](#known-limitations) | +| `game_limits.*`, `display_options.*` | as above | **Advanced.** Nested copies of the game-limit keys (`recent_games_to_show` … `other_games_divisions`) and of `show_records` / `show_ranking` / `show_odds`. Both copies render in the web UI and both are read: a nested value you changed from its default wins, otherwise the root key decides. Set one place or the other, not both | | `customization.favorite_result_colors.enabled` | `false` | Colour a finished game's score by whether your favourite won | | `customization.favorite_result_colors.win_color` | `[0, 255, 0]` | **Advanced.** Colour for a win | | `customization.favorite_result_colors.loss_color` | `[255, 0, 0]` | **Advanced.** Colour for a loss | @@ -414,8 +417,10 @@ scroll mode. | `scroll_card.switch_date_format` | `numeric` | `numeric` (9/4), `abbrev` (Sep 4), `day_first` (4 Sep), `numeric_day_first` (4/9), `weekday` (Fri Sep 4), `inherit` | | `scroll_card.date_format` | `abbrev` | Same set, minus `inherit` | | `scroll_card.time_format` | `12h` | `12h` (7:40PM) or `24h` (19:40) | -| `scroll_card.show_date` | `true` | Draw the date at all | -| `scroll_card.show_time` | `true` | Draw the start time at all | +| `scroll_card.show_date` | `true` | Draw the date on the scroll and Vegas cards | +| `scroll_card.show_time` | `true` | Draw the start time on the scroll and Vegas cards | +| `scroll_card.switch_show_date` | `true` | Draw the date on the full-screen upcoming scoreboard | +| `scroll_card.switch_show_time` | `true` | Draw the start time on the full-screen upcoming scoreboard | | `scroll_card.switch_recent_show_date` | `true` | Draw the game's date along the bottom of the full-screen recent scoreboard, written in `switch_date_format` | | `scroll_card.swap_date_time` | `false` | Put the time above the date instead of below | | `scroll_card.center_gap` | *(auto)* | Fixed pixel gap in the middle of a scroll card | @@ -437,7 +442,7 @@ Scroll-mode-only settings: | Option | Default | What it does | |--------|---------|--------------| -| `scroll_settings.scroll_speed` | `1.0` | **Advanced.** Pixels per step | +| `scroll_settings.scroll_speed` | `50` | **Advanced.** Scroll speed in pixels per second (0.01–200) | | `scroll_settings.scroll_delay` | `0.01` | **Advanced.** Ignored; kept so saved configs still load. Scrolling is paced to the panel refresh; `scroll_speed` sets the speed. **Hidden from the config form since 1.30.0 (still declared).** | | `scroll_settings.gap_between_games` | `24` | **Advanced.** Blank pixels between cards | | `scroll_settings.game_card_width` | `128` | **Advanced.** Width of one card | @@ -484,6 +489,7 @@ Every text element on the card can be restyled independently. All of these are | `customization.status_text` | `4x6-font.ttf` | `6` | `[255, 255, 255]` | | `customization.detail_text` | `4x6-font.ttf` | `6` | `[255, 255, 255]` | | `customization.rank_text` | `PressStart2P-Regular.ttf` | `10` | `[255, 255, 255]` | +| `customization.odds_text` | `4x6-font.ttf` | `6` | `[0, 255, 0]` | Each group takes `font`, `font_size` and `text_color` (an RGB array): diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 73f23b02..32a8b6b7 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.35.1", + "version": "1.35.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.35.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "README only: scroll_settings.scroll_speed is documented with its real default (50 px/s, not 1.0); documents odds_update_interval / live_odds_update_interval, the full-screen scroll_card.switch_show_date / switch_show_time switches, customization.odds_text, and the nested game_limits / display_options copies and which one wins. No change in behaviour." + }, { "version": "1.35.1", "released": "2026-10-01", @@ -531,7 +537,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/afl-scoreboard/test_afl_plugin.py b/plugins/afl-scoreboard/test_afl_plugin.py index 477bfd32..47d8c4a9 100644 --- a/plugins/afl-scoreboard/test_afl_plugin.py +++ b/plugins/afl-scoreboard/test_afl_plugin.py @@ -36,6 +36,16 @@ def _install_thirdparty_stubs() -> None: """ def _mod(name, **attrs): m = sys.modules.get(name) + if m is None: + # Prefer the real package when it is installed: a stubbed + # ``requests`` is not a package, so a core that imports + # ``requests.models`` (fetch_service) would fail to load. + try: + import importlib + # name is one of the fixed module names passed below. + m = importlib.import_module(name) # nosemgrep + except ImportError: + m = None if m is None: m = types.ModuleType(name) sys.modules[name] = m diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index e5880bab..466e3911 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.57.0] - 2026-10-02 + +### Added +- `customization.layout.time` (`x_offset` / `y_offset`, advanced). The start + time on the upcoming scoreboard and on upcoming cards has always read this + offset, but the schema had no `time` group and sets + `additionalProperties: false`, so it could not be set. + +### Changed +- `customization.layout.record.x_offset` is hidden: nothing reads it. The two + records sit at opposite edges and move with `away_x_offset` / + `home_x_offset`. The README said it shifted both records; it now says it is + ignored. Kept declared so saved configs keep validating. +- README documents `scroll_card.switch_show_date` / `switch_show_time`, + `customization.odds_text` and the full list of layout groups. + ## [1.56.1] - 2026-10-02 ### Changed diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 43898881..535c19f5 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -746,7 +746,8 @@ and the full-screen scoreboard. | `date_format` | `abbrev` | Scroll and Vegas: `abbrev` (Sep 19), `numeric` (9/19), `day_first` (19 Sep), `numeric_day_first` (19/9), `weekday` (Fri Sep 19) | | `switch_date_format` | `numeric` | The same set for the full-screen scoreboard, plus `inherit` | | `time_format` | `12h` | `12h` (7:40PM) or `24h` (19:40) | -| `show_date` / `show_time` | `true` | Drop either line **on an upcoming card** | +| `show_date` / `show_time` | `true` | Drop either line **on an upcoming card** (scroll and Vegas only) | +| `switch_show_date` / `switch_show_time` | `true` | The same for the full-screen upcoming scoreboard. Separate switches because the originals predate that display reading this block, and sharing them would have changed what existing boards draw | | `swap_date_time` | `false` | Swap the two lines. Each display starts from its own order, so this flips rather than forces: cards put the time on top, the full-screen stack puts the date on top | The two `*_date_format` keys have different defaults on purpose: the cards have @@ -882,8 +883,10 @@ full-screen scoreboard and on the scroll and Vegas cards alike. | `status_text` | Status lines such as "Next Game" | | `detail_text` | Small detail lines | | `rank_text` | Team rankings (unused here — no baseball league publishes a poll) | +| `odds_text` | The spread and over/under (4x6 at 6 by default, like `detail_text`) | -Colours are `[r, g, b]` or `"#RRGGBB"`, and every default is white: +Colours are `[r, g, b]` or `"#RRGGBB"`, and every default is white except +`odds_text`, which defaults to the green the odds have always used (`[0, 255, 0]`): ```json { @@ -894,15 +897,18 @@ Colours are `[r, g, b]` or `"#RRGGBB"`, and every default is white: ``` `customization.layout` nudges individual elements by `x_offset` / `y_offset` -for panels where something sits slightly wrong. The record group takes two extra +for panels where something sits slightly wrong. Its groups are `home_logo`, +`away_logo`, `score`, `date`, `time`, `status`, `record` and `odds`, each +defaulting to `0`/`0` (`ranking` is hidden and ignored: the rank badge sits in +the record slot). The record group takes two extra keys, because the two records sit at opposite edges: | Option | Default | What it does | |--------|---------|--------------| -| `customization.layout.record.x_offset` | `0` | Shifts both records. | +| `customization.layout.record.x_offset` | `0` | Ignored and hidden in the settings form; nothing reads it. Use the two keys below. | | `customization.layout.record.y_offset` | `0` | Vertical nudge for both. | -| `customization.layout.record.away_x_offset` | `0` | Extra horizontal shift for the away record alone. | -| `customization.layout.record.home_x_offset` | `0` | Extra horizontal shift for the home record alone. | +| `customization.layout.record.away_x_offset` | `0` | Horizontal shift for the away record, drawn from the left edge. | +| `customization.layout.record.home_x_offset` | `0` | Horizontal shift for the home record, drawn from the right edge. | The centre-gap settings size the strip kept clear down the middle of a scroll or Vegas card, so the score is not drawn over the team logos. They do not affect the diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 137fd1a1..17a01d85 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -2534,6 +2534,26 @@ }, "additionalProperties": false }, + "time": { + "type": "object", + "title": "Game Time", + "description": "Moves the start time on the upcoming scoreboard and on an upcoming card.", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, "status": { "type": "object", "title": "Game Status/Inning", @@ -2560,9 +2580,10 @@ "properties": { "x_offset": { "x-advanced": true, + "x-display": "hidden", "type": "integer", "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "description": "Horizontal offset from default position (default: 0) HIDDEN: nothing reads it. The two records sit at opposite edges and move with away_x_offset / home_x_offset. Kept declared so saved configs keep validating." }, "y_offset": { "x-advanced": true, @@ -2574,13 +2595,13 @@ "x-advanced": true, "type": "integer", "default": 0, - "description": "Additional horizontal offset for away team record (default: 0)" + "description": "Horizontal offset for the away record, drawn from the left edge (default: 0)" }, "home_x_offset": { "x-advanced": true, "type": "integer", "default": 0, - "description": "Additional horizontal offset for home team record (default: 0)" + "description": "Horizontal offset for the home record, drawn from the right edge (default: 0)" } }, "additionalProperties": false, @@ -2632,6 +2653,7 @@ "away_logo", "score", "date", + "time", "status", "record", "ranking", diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 842e896b..eed7544f 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.56.1", + "version": "1.57.0", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.57.0", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "Adds customization.layout.time (advanced): the upcoming start time has always read this offset, but the schema had no time group and forbids extra keys, so it could not be set. Hides customization.layout.record.x_offset, which nothing reads (the records move with away_x_offset / home_x_offset; the README said it shifted both). README documents scroll_card.switch_show_date / switch_show_time, customization.odds_text and the layout groups." + }, { "version": "1.56.1", "released": "2026-10-02", @@ -836,7 +842,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/basketball-scoreboard/CHANGELOG.md b/plugins/basketball-scoreboard/CHANGELOG.md index b848530a..7e49d6be 100644 --- a/plugins/basketball-scoreboard/CHANGELOG.md +++ b/plugins/basketball-scoreboard/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.42.0] - 2026-10-02 + +### Added +- `customization.layout.record.away_x_offset` / `home_x_offset`, + `customization.layout.date` and `customization.layout.time` (advanced). + The scoreboard and cards have always read these offsets, but the schema did + not declare them and sets `additionalProperties: false`, so none could be + set: the records could only be moved vertically, and the recent date and the + upcoming card's date and time not at all. + +### Changed +- `customization.layout.record.x_offset` is hidden: nothing reads it (the + records move with the two keys above). Kept declared so saved configs keep + validating. +- README: the layout table named elements the schema does not have + (`status_text`, `records`); it now lists the real ones. Documents + `scroll_card.switch_show_date` / `switch_show_time` and the per-league + `odds_update_interval` / `live_odds_update_interval`. + ## [1.41.2] - 2026-10-02 ### Changed diff --git a/plugins/basketball-scoreboard/README.md b/plugins/basketball-scoreboard/README.md index 0a4061d9..9269ab37 100644 --- a/plugins/basketball-scoreboard/README.md +++ b/plugins/basketball-scoreboard/README.md @@ -357,6 +357,8 @@ All **Advanced**. | `.live_update_interval` | 5–300 s | `30` | How often live game data refreshes. | | `.recent_update_interval` | 60–86400 s | `3600` | How often the finished-games list is rebuilt. This also sets how soon a game that has just ended can appear — lower it if you want results sooner. | | `.upcoming_update_interval` | 60–86400 s | `3600` | How often the upcoming-games list is rebuilt. Selection and the non-favorite rotation both run on the display side, so this governs only the fetch. | +| `.odds_update_interval` | 60–86400 s | `3600` | **Advanced.** How often betting odds are refreshed for recent and upcoming games. Only matters when Show Odds is on. | +| `.live_odds_update_interval` | 30–3600 s | `60` | **Advanced.** How often odds are refreshed for games in progress. Only matters when Show Odds is on. | | `.stale_game_timeout` | 60–3600 s | `300` | Drop a live game the API has stopped updating. | ### Display options @@ -423,7 +425,8 @@ Plugin-wide, not per league. | Date Format | `scroll_card.date_format` | `abbrev` | Scroll and Vegas cards: `Sep 19`, `9/19`, `19 Sep`, `19/9`, or `Fri Sep 19`. | | Full-Screen Date Format | `scroll_card.switch_date_format` | `numeric` | **Advanced.** The same for the full-screen scoreboard, plus `inherit`. It has its own default because the two displays disagree about what is normal. | | Time Format | `scroll_card.time_format` | `12h` | 12- or 24-hour clock. | -| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line. | +| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line from the scroll and Vegas cards. | +| Full-Screen Show Date / Show Time | `scroll_card.switch_show_date`, `scroll_card.switch_show_time` | `true` | The same for the full-screen scoreboard. Separate switches because the originals predate this display reading the block, and sharing them would have changed what existing boards draw. | | Full-Screen Recent Date | `scroll_card.switch_recent_show_date` | `true` | Draw the date a finished game was played along the bottom of the full-screen recent scoreboard, written in the Full-Screen Date Format. | | Swap Date and Time | `scroll_card.swap_date_time` | `false` | Flip the two lines. Each display starts from its own order, so this flips rather than forces. | @@ -475,10 +478,10 @@ Nudge any element in pixels. All default to `0`, all **Advanced**, all under |---|---|---| | `home_logo`, `away_logo` | `x_offset`, `y_offset` | Default logo position | | `score` | `x_offset`, `y_offset` | Panel centre | -| `status_text` | `x_offset`, `y_offset` | Centre horizontally, top vertically | -| `date` | `x_offset`, `y_offset` | Centre horizontally, default position vertically | -| `time` | `x_offset`, `y_offset` | Centre horizontally, the date's position vertically | -| `records` | `away_x_offset`, `home_x_offset`, `y_offset` | Away from the left, home from the right, both from the bottom | +| `status` | `x_offset`, `y_offset` | Centre horizontally, top vertically. Also moves the date and time on an upcoming scoreboard, which are drawn as status text | +| `date` | `x_offset`, `y_offset` | The date along the bottom of a recent scoreboard, and the date on an upcoming scroll or Vegas card | +| `time` | `x_offset`, `y_offset` | The start time on an upcoming scroll or Vegas card | +| `record` | `away_x_offset`, `home_x_offset`, `y_offset` | Away from the left, home from the right, both from the bottom. The rank badge sits in the same slot. `record.x_offset` is hidden and ignored | | `odds` | `x_offset`, `y_offset` | Default odds position | ## Favorite team result colours diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 5e26550e..2faceb36 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -2940,15 +2940,16 @@ }, "additionalProperties": false }, - "record": { + "date": { "type": "object", - "title": "Team Records", + "title": "Game Date", + "description": "Moves the date along the bottom of the full-screen recent scoreboard, and the date on an upcoming scroll or Vegas card. (The full-screen upcoming date and time move with Game Status.)", "properties": { "x_offset": { "x-advanced": true, "type": "integer", "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "description": "Horizontal offset from center (default: 0)" }, "y_offset": { "x-advanced": true, @@ -2959,6 +2960,59 @@ }, "additionalProperties": false }, + "time": { + "type": "object", + "title": "Game Time", + "description": "Moves the start time on an upcoming scroll or Vegas card.", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "record": { + "type": "object", + "title": "Team Records", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0) HIDDEN: nothing reads it. The two records sit at opposite edges and move with Away / Home Horizontal Offset. Kept declared so saved configs keep validating.", + "x-display": "hidden" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset for both records (default: 0)" + }, + "away_x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset for the away record, drawn from the left edge (default: 0)" + }, + "home_x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset for the home record, drawn from the right edge (default: 0)" + } + }, + "additionalProperties": false, + "description": "Moves the win-loss record (or rank badge) in the bottom corners. The rank badge is drawn in the record slot, so these are its offsets too." + }, "ranking": { "type": "object", "title": "Team Rankings", @@ -3005,6 +3059,8 @@ "away_logo", "score", "status", + "date", + "time", "record", "ranking", "odds" diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 57182547..1961b8bc 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.41.2", + "version": "1.42.0", "update_interval": 60, "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", @@ -19,6 +19,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.42.0", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "Adds customization.layout.record.away_x_offset / home_x_offset and the layout.date and layout.time groups (advanced). The scoreboard and cards have always read these offsets, but the schema did not declare them and forbids extra keys, so the records could only be moved vertically and the recent date and upcoming card date/time not at all. Hides layout.record.x_offset, which nothing reads. README: the layout table named elements the schema does not have; it now lists the real ones, and documents scroll_card.switch_show_date / switch_show_time and the per-league odds_update_interval / live_odds_update_interval." + }, { "version": "1.41.2", "released": "2026-10-02", diff --git a/plugins/birdnet-go/README.md b/plugins/birdnet-go/README.md index adb09327..3d9f8df3 100644 --- a/plugins/birdnet-go/README.md +++ b/plugins/birdnet-go/README.md @@ -117,7 +117,7 @@ Every `birdnet_api.poll_interval` seconds (default 60) the plugin calls: | `/api/v2/analytics/species/daily` | today's per-species counts | | `/api/v2/media/species-image?name=` | the species photo | -Photos are cached in memory and on disk for 30 days, keyed by scientific name; failed lookups are remembered for the session so we don't hammer the API. If the image endpoint is unreachable the layout falls back to text-only. +Photos are cached in memory and on disk for 30 days, keyed by scientific name; a failed lookup is not retried for 30 minutes, so the API isn't hammered but a photo that timed out on its first request still turns up. If the image endpoint is unreachable the layout falls back to text-only. Polling keeps running even when MQTT is enabled, so a broker outage can't freeze the display. diff --git a/plugins/birdnet-go/manager.py b/plugins/birdnet-go/manager.py index 95f38c40..504a3f1e 100644 --- a/plugins/birdnet-go/manager.py +++ b/plugins/birdnet-go/manager.py @@ -53,6 +53,9 @@ _MAX_SOURCE_IMAGES = 32 _MAX_PANEL_IMAGES = 16 +# Seconds before a species whose image fetch failed is tried again. +_IMAGE_RETRY_S = 1800 + # Narrow fallback face for long names beside the photo; crisp only at 7px. NARROW_FONT_PATH = 'assets/fonts/4x6-font.ttf' NARROW_FONT_SIZE = 7 @@ -156,7 +159,10 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # species -> decoded source PIL, and (species, w, h) -> panel-sized frame. self._species_img_cache: "OrderedDict[str, Image.Image]" = OrderedDict() self._panel_img_cache: "OrderedDict[Tuple[str, int, int], Image.Image]" = OrderedDict() - self._species_img_failed: set = set() # species we already failed to fetch + # species -> when its image fetch last failed; retried after + # _IMAGE_RETRY_S. A permanent set meant one timeout (BirdNET-Go + # downloads a photo upstream on first request) hid it until restart. + self._species_img_failed: Dict[str, float] = {} self._pending_image_fetch: Optional[str] = None self._scroll_pos = 0.0 self._scroll_cache: Optional[Image.Image] = None @@ -703,7 +709,7 @@ def _matrix_dims(self) -> Tuple[int, int]: def _fetch_species_image(self, species: str) -> Optional[Image.Image]: if not self.api_base_url or not species: return None - if species in self._species_img_failed: + if self._image_failed_recently(species): return None # Disk cache via cache_manager (base64 of PNG bytes) @@ -723,7 +729,7 @@ def _fetch_species_image(self, species: str) -> Optional[Image.Image]: resp = requests.get(url, timeout=self.api_timeout) if resp.status_code != 200: self.logger.warning("Species image HTTP %s for %s", resp.status_code, species) - self._species_img_failed.add(species) + self._species_img_failed[species] = time.time() return None img = Image.open(BytesIO(resp.content)) img.load() @@ -738,9 +744,13 @@ def _fetch_species_image(self, species: str) -> Optional[Image.Image]: return img except Exception as e: self.logger.warning("Error fetching species image for %s: %s", species, e) - self._species_img_failed.add(species) + self._species_img_failed[species] = time.time() return None + def _image_failed_recently(self, species: str) -> bool: + failed_at = self._species_img_failed.get(species) + return failed_at is not None and time.time() - failed_at < _IMAGE_RETRY_S + def _resize_image(self, img: Image.Image, box_w: int, box_h: int) -> Image.Image: """Fill the box edge to edge, zoomed in so the bird isn't a speck. @@ -1111,7 +1121,7 @@ def update(self) -> None: deadline = time.time() + max(2.0, self.api_timeout * 1.5) for species in wanted: if (not species or species in self._species_img_cache - or species in self._species_img_failed): + or self._image_failed_recently(species)): continue if time.time() >= deadline: self.logger.debug("Image warm-up budget spent; resuming next update") diff --git a/plugins/birdnet-go/manifest.json b/plugins/birdnet-go/manifest.json index b6eb146b..f4ddcc38 100644 --- a/plugins/birdnet-go/manifest.json +++ b/plugins/birdnet-go/manifest.json @@ -1,7 +1,7 @@ { "id": "birdnet-go", "name": "BirdNET-Go", - "version": "1.2.7", + "version": "1.2.8", "author": "ChuckBuilds", "description": "Show what BirdNET-Go is hearing. One screen cycles a different species each turn \u2014 name, confidence, how many times it has been heard today, and a photo \u2014 and a second shows today's stats: species count, total detections and the most-heard species. Polls the BirdNET-Go REST API, with optional MQTT for instant pop-ups.", "category": "integration", @@ -21,6 +21,12 @@ "entry_point": "manager.py", "class_name": "BirdNetGoPlugin", "versions": [ + { + "released": "2026-10-02", + "version": "1.2.8", + "changelog": "A species photo that failed to load is retried after 30 minutes instead of never. BirdNET-Go downloads a photo upstream on its first request, so one slow first fetch used to leave that species text-only until a restart.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-28", "version": "1.2.7", @@ -84,7 +90,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/blackjack/config_schema.json b/plugins/blackjack/config_schema.json index 257b3f6e..cd2fa4a3 100644 --- a/plugins/blackjack/config_schema.json +++ b/plugins/blackjack/config_schema.json @@ -106,7 +106,7 @@ "minimum": 0, "maximum": 255 }, - "x-widget": "color" + "x-widget": "color-picker" }, "player_color": { "type": "array", @@ -124,7 +124,7 @@ "minimum": 0, "maximum": 255 }, - "x-widget": "color" + "x-widget": "color-picker" }, "felt_color": { "type": "array", @@ -142,7 +142,7 @@ "minimum": 0, "maximum": 255 }, - "x-widget": "color" + "x-widget": "color-picker" }, "card_interval": { "type": "number", diff --git a/plugins/blackjack/manifest.json b/plugins/blackjack/manifest.json index b92543a9..b8dfdf02 100644 --- a/plugins/blackjack/manifest.json +++ b/plugins/blackjack/manifest.json @@ -1,7 +1,7 @@ { "id": "blackjack", "name": "Blackjack", - "version": "1.3.1", + "version": "1.3.2", "author": "ChuckBuilds", "description": "Deals one hand of Las Vegas blackjack per rotation. Cards slide in and flip face up a few seconds apart, the player follows basic strategy, the dealer plays the house rules, and the hand ends on a BLACKJACK! / BUST! / PUSH banner. Every hand comes off a real shuffled shoe, so no two rotations are the same.", "entry_point": "manager.py", @@ -34,6 +34,12 @@ "update_interval": 3600, "default_duration": 22, "versions": [ + { + "version": "1.3.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.2.0", + "notes": "The three colour settings (dealer, player, felt) use the web UI's color-picker widget. They declared x-widget color, which the web UI does not recognise, so they showed as plain comma-separated text boxes. Values and defaults are unchanged." + }, { "version": "1.3.1", "released": "2026-10-02", diff --git a/plugins/calendar/README.md b/plugins/calendar/README.md index 925141ca..f75c9103 100644 --- a/plugins/calendar/README.md +++ b/plugins/calendar/README.md @@ -49,10 +49,10 @@ size and scaled up so the pixels stay pixels.* 1. In Cloud Console, go to **APIs & Services → Credentials** 2. Click **Create Credentials → OAuth client ID** -3. Choose **Desktop application** — the plugin uses - `InstalledAppFlow.run_local_server()` - (`plugins/calendar/manager.py:346-348`), which requires this client - type. "TV and Limited Input Device" will not work. +3. Choose **Desktop application**. The sign-in flow in + `calendar_registration.py` redirects to a loopback address + (`http://127.0.0.1`), which Google only allows for this client type. + "TV and Limited Input Device" will not work. 4. Download the JSON file 5. Save it as `credentials.json` in the calendar plugin directory (typically `plugin-repos/calendar/credentials.json`) @@ -64,15 +64,20 @@ size and scaled up so the pixels stay pixels.* 2. Open the **Calendar** tab in the second nav row (added once the plugin is installed) 3. Click the **Authenticate Google Calendar** button -4. Follow the OAuth flow in your browser -5. The token is saved automatically into the plugin directory +4. Open the link it gives you and approve access. Google then redirects your + browser to a `http://127.0.0.1/...` page that fails to load -- that is + expected. Copy that page's full address and paste it back into the web UI +5. The token is saved automatically into the plugin directory as `token.pickle` **Option B: Use Registration Script** ```bash -cd plugins/calendar +cd plugin-repos/calendar python calendar_registration.py ``` +Run from a terminal, the script opens a local browser for the consent screen, +so it needs a machine with a desktop browser. On a headless Pi use Option A. + **The plugin never signs in by itself.** If it has no usable token (none yet, or a refresh fails because access was revoked or the token expired), it logs the problem and the panel shows **Auth needed / See web UI** instead of events. @@ -110,7 +115,7 @@ widget and first-time authentication) and rarely need editing by hand. | `enabled` | `false` | Enable or disable the plugin | | `credentials_file` | `"credentials.json"` | Google OAuth credentials file (uploaded via the config UI) | | `google_auth` | `""` | Not a setting you type into. It is the **Step 2: Connect Your Google Account** button in the web UI (`x-widget: google-oauth`), which runs the consent flow described above. Google redirects to a page that fails to load — that is expected; copy the address back into the field to finish. | -| `token_file` | `"token.pickle"` | Where the OAuth token is stored after first-time auth (auto-created) | +| `token_file` | `"token.pickle"` | Token file the plugin reads, relative to the plugin directory. Not in the web form. The sign-in flow always writes `token.pickle`, so leave this at the default | | `calendars` | `["primary"]` | Calendar IDs to display — use the calendar picker after authenticating, or `"primary"` for your default | | `max_events` | `3` | Maximum upcoming events to show (1–10) | | `show_all_day_events` | `true` | Include all-day events | @@ -168,8 +173,9 @@ Events from all calendars are merged and sorted by start time. ### Update Frequency - Set `update_interval` to balance freshness vs. API usage -- 300 seconds (5 minutes) is recommended -- Lower values may hit API rate limits +- The default is 3600 seconds (hourly); 300 (5 minutes) suits a calendar that + changes during the day +- Each refresh makes one request per configured calendar ### Timezone @@ -212,9 +218,11 @@ See [List of timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_z - Review logs for errors **Long titles cut off:** -- Text automatically wraps to 2 lines -- Titles longer than 2 lines will be truncated -- Consider shortening event names in Google Calendar +- Titles wrap onto as many lines as fit below the date (two on a 32-pixel-tall + panel with the default fonts, four at 64 pixels) +- When the title needs more lines than fit, the last visible line ends in `...` +- Use a smaller `customization.title_text` font, or shorten event names in + Google Calendar ## Calendar IDs @@ -238,8 +246,8 @@ Google Calendar API has rate limits: - **Free tier**: 1,000,000 queries per day - **Per user**: 10 requests per second -With default settings (300s interval): -- 288 requests per day (well within limits) +With the default 3600s interval: 24 requests per calendar per day. At 300s: +288 per calendar per day. Both are well within the limits. ## Security Notes diff --git a/plugins/calendar/manager.py b/plugins/calendar/manager.py index 74a698ea..97a70a9d 100644 --- a/plugins/calendar/manager.py +++ b/plugins/calendar/manager.py @@ -189,7 +189,9 @@ def _load_fonts(self): title_settings = customization.get('title_text', {}) # Get font names and sizes from config (with defaults) - datetime_font_name = datetime_settings.get('font', '4x6-font.ttf') + # Mirrors config_schema.json, whose default is PressStart2P for + # both elements; a normal load merges that default in anyway. + datetime_font_name = datetime_settings.get('font', 'PressStart2P-Regular.ttf') datetime_font_size = datetime_settings.get('font_size', 8) title_font_name = title_settings.get('font', 'PressStart2P-Regular.ttf') title_font_size = title_settings.get('font_size', 8) @@ -452,8 +454,10 @@ def update(self) -> None: ) continue - # Sort all events by start time - all_events.sort(key=lambda x: x['start'].get('dateTime', x['start'].get('date', ''))) + # Sort all events by start time. Compare instants, not strings: + # each calendar's dateTime carries its own UTC offset, so text order + # put e.g. 10:00-07:00 ahead of 12:00-04:00 (an hour earlier). + all_events.sort(key=self._event_start_ts) # Limit to max_events self.events = all_events[:self.max_events] @@ -730,7 +734,26 @@ def _format_event_time(self, event: Dict) -> str: self.logger.warning(f"Error formatting event time: {e}") return 'All Day' - + + def _event_start_ts(self, event: Dict) -> float: + """Start of an event as a UNIX timestamp, for merging calendars. + + An all-day event starts at local midnight of its date, so it still + sorts ahead of that day's timed events. Unparseable starts sort last. + """ + start = event.get('start', {}) + try: + if 'dateTime' in start: + return datetime.fromisoformat(start['dateTime'].replace('Z', '+00:00')).timestamp() + if 'date' in start: + day = datetime.fromisoformat(start['date']) + if self.timezone is not None and hasattr(self.timezone, 'localize'): + return self.timezone.localize(day).timestamp() + return day.replace(tzinfo=timezone.utc).timestamp() + except (TypeError, ValueError, AttributeError) as e: + self.logger.warning(f"Error reading event start for sorting: {e}") + return float('inf') + def _display_no_events(self): """Display message when no events are available.""" width, height = self._fresh_frame() diff --git a/plugins/calendar/manifest.json b/plugins/calendar/manifest.json index 2fbe6087..052d82b1 100644 --- a/plugins/calendar/manifest.json +++ b/plugins/calendar/manifest.json @@ -1,7 +1,7 @@ { "id": "calendar", "name": "Google Calendar", - "version": "1.2.10", + "version": "1.2.11", "author": "ChuckBuilds", "description": "Display upcoming events from Google Calendar with date, time, and event details. Shows next 1-3 events with automatic rotation and timezone support.", "category": "productivity", @@ -35,6 +35,12 @@ } ], "versions": [ + { + "version": "1.2.11", + "released": "2026-10-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Events merged from several calendars are ordered by when they actually start. They were sorted by the start text, and each calendar writes its times with its own UTC offset, so a 10:00-07:00 event sorted ahead of a 12:00-04:00 one that starts an hour earlier -- and with max_events applied after the sort, the earlier event could be dropped. The code default for the date/time font now matches the schema (PressStart2P); a normal load merges the schema default, so rendering is unchanged. README: the setup step no longer cites the removed run_local_server() call, the web sign-in step describes pasting back the 127.0.0.1 address, the registration script is noted as needing a desktop browser, token_file is noted as best left at its default (sign-in always writes token.pickle), and the update-interval and long-title notes match the code (default 3600s; titles wrap to as many lines as fit). Core floor unchanged." + }, { "version": "1.2.10", "released": "2026-10-02", diff --git a/plugins/christmas-countdown/README.md b/plugins/christmas-countdown/README.md index 5b707568..5bdfbc33 100644 --- a/plugins/christmas-countdown/README.md +++ b/plugins/christmas-countdown/README.md @@ -36,15 +36,17 @@ The text has three states, driven by the date: | When | Shows | |------|-------| -| Before 25 December | `N DAYS UNTIL CHRISTMAS` | +| Before 25 December | `N DAYS UNTIL CHRISTMAS` (`1 DAY UNTIL CHRISTMAS` on the 24th) | | On 25 December | `MERRY CHRISTMAS` | -| After 25 December | `MERRY CHRISTMAS`, until the count to next year begins | +| From 26 December | The count to next year's Christmas, e.g. `364 DAYS UNTIL CHRISTMAS` | ![The countdown 114 days out, a week out, the day before, and on Christmas Day itself](../../docs/assets/christmas-countdown/countdown.png) -On a panel **narrower than 64 pixels** the last word is abbreviated to `XMAS` -so the text still fits. The count is computed from today's date in the +When `CHRISTMAS` does not fit the right half of the panel in the smallest font +(any panel narrower than about 93 pixels, so every 64-pixel-wide panel) the +last word is abbreviated to `XMAS` so the text still fits; 96 pixels and wider +spell it out. The count is computed from today's date in the LEDMatrix `timezone` setting (the host's system time if none is set) every time the screen is drawn, so it changes at midnight in that timezone. @@ -120,24 +122,26 @@ panels](../../docs/assets/christmas-countdown/panel-sizes.png) ## The Tree Image -The tree is `assets/christmas_tree.png`, a small pixel-art PNG that ships with -the plugin and is scaled to the space available. +The tree is `tree icon.png` in the plugin's own directory, a 256×256 image that +ships with the plugin and is scaled down to the space available. If that file +is missing the plugin falls back to `assets/christmas_tree.png`, a small 32×32 +pixel-art tree that also ships with it. -If that file is missing, the plugin draws a simple tree programmatically +If both files are missing, the plugin draws a simple tree programmatically instead — and **that** is the only situation in which `tree_color` applies. With -the bundled image present, as it is on any normal install, `tree_color` has no -visible effect. The schema says so; it is repeated here because "green tree +the bundled images present, as they are on any normal install, `tree_color` has +no visible effect. The schema says so; it is repeated here because "green tree colour" reads like a setting that should work. -To regenerate the bundled image: +To regenerate the fallback image: ```bash python3 generate_tree_image.py ``` That script writes `assets/christmas_tree.png` and nothing else — it is an -asset generator, not a preview of the plugin, so it cannot drift from what the -plugin draws. +asset generator, not a preview of the plugin, and it does not touch +`tree icon.png`, which is the image normally drawn. --- @@ -153,16 +157,18 @@ system timezone, which is used when none is set) if it disagrees with your calendar. **It says MERRY CHRISTMAS in July.** -It should not — that message is shown on and shortly after 25 December only. If -you see it out of season, check the system date. +It should not — that message is shown on 25 December only; from the 26th the +count to next year starts. If you see it out of season, check the system date +and the LEDMatrix `timezone` setting. **The text says XMAS instead of CHRISTMAS.** -That is deliberate on panels narrower than 64 pixels, where the full word does -not fit. +That is deliberate on panels narrower than about 93 pixels (every 64-pixel-wide +panel), where the full word does not fit the right half. **I changed the tree colour and nothing happened.** -`tree_color` only applies when `assets/christmas_tree.png` is missing. With the -bundled image in place the tree comes from the PNG. +`tree_color` only applies when both `tree icon.png` and +`assets/christmas_tree.png` are missing. With the bundled images in place the +tree comes from the PNG. **I changed the tree size and nothing happened.** `tree_size` is not applied — see @@ -180,9 +186,11 @@ christmas-countdown/ ├── manager.py # ChristmasCountdownPlugin ├── config_schema.json # Settings schema; source of truth for defaults ├── generate_tree_image.py # Regenerates assets/christmas_tree.png +├── tree icon.png # The tree normally drawn ├── assets/ -│ └── christmas_tree.png -├── test/ +│ └── christmas_tree.png # Fallback tree when tree icon.png is missing +├── test/ # Harness fixture and golden images +├── test_christmas_day_count.py └── README.md ``` @@ -202,6 +210,13 @@ harness can check it renders correctly at every panel size: python scripts/check_plugin.py --plugin christmas-countdown --plugin-dir /path/to/ledmatrix-plugins/plugins --out-dir /tmp/preview ``` +The day-count regression tests (count before the first `update()`, midnight +rollover, the LEDMatrix timezone) run against a core checkout: + +```bash +LEDMATRIX_CORE=/path/to/LEDMatrix python plugins/christmas-countdown/test_christmas_day_count.py +``` + To watch it live in the emulator instead: ```bash diff --git a/plugins/christmas-countdown/manager.py b/plugins/christmas-countdown/manager.py index 69111319..32f91d0d 100644 --- a/plugins/christmas-countdown/manager.py +++ b/plugins/christmas-countdown/manager.py @@ -2,13 +2,13 @@ Christmas Countdown Plugin for LEDMatrix Displays a countdown to Christmas with a stylized Christmas tree logo -and festive text. Shows "MERRY CHRISTMAS" on and after Christmas Day. +and festive text. Shows "MERRY CHRISTMAS" on Christmas Day. Features: - Stylized Christmas tree logo (image or programmatic fallback) - Adaptive text: "N DAYS UNTIL CHRISTMAS" or "N DAYS UNTIL XMAS" on smaller displays - Traditional holiday colors (green tree, red text) -- Automatic "MERRY CHRISTMAS" message on/after Dec 25 +- Automatic "MERRY CHRISTMAS" message on Dec 25 API Version: 1.0.0 """ @@ -487,10 +487,11 @@ def display(self, force_clear: bool = False) -> None: if self.is_christmas or self.days_until_christmas == 0: message = "MERRY CHRISTMAS" else: + day_word = "DAY" if self.days_until_christmas == 1 else "DAYS" if use_xmas: - message = f"{self.days_until_christmas} DAYS UNTIL XMAS" + message = f"{self.days_until_christmas} {day_word} UNTIL XMAS" else: - message = f"{self.days_until_christmas} DAYS UNTIL CHRISTMAS" + message = f"{self.days_until_christmas} {day_word} UNTIL CHRISTMAS" # Check if we need to redraw (prevent blinking) # Only redraw if the message changed or force_clear is True @@ -539,7 +540,7 @@ def display(self, force_clear: bool = False) -> None: if len(parts) >= 4: lines = [ f"{self.days_until_christmas}", - "DAYS", + day_word, "UNTIL", "XMAS" ] @@ -551,7 +552,7 @@ def display(self, force_clear: bool = False) -> None: if len(parts) >= 4: lines = [ f"{self.days_until_christmas}", - "DAYS", + day_word, "UNTIL", "CHRISTMAS" ] diff --git a/plugins/christmas-countdown/manifest.json b/plugins/christmas-countdown/manifest.json index a22bee43..03809fca 100644 --- a/plugins/christmas-countdown/manifest.json +++ b/plugins/christmas-countdown/manifest.json @@ -1,7 +1,7 @@ { "id": "christmas-countdown", "name": "Christmas Countdown", - "version": "2.0.3", + "version": "2.0.4", "author": "ChuckBuilds", "description": "Display a countdown to Christmas with a stylized Christmas tree logo and festive text", "category": "holiday", @@ -17,6 +17,12 @@ "christmas-countdown" ], "versions": [ + { + "version": "2.0.4", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "On 24 December the panel reads 1 DAY UNTIL CHRISTMAS (or XMAS) instead of 1 DAYS. Documentation: the README said MERRY CHRISTMAS stays up after the 25th; from the 26th the plugin counts to next year's Christmas. It put the XMAS abbreviation below 64px wide, but every 64px-wide panel abbreviates and 96px and wider spell it out (the cut is whether CHRISTMAS fits the right half, about 93px). And it named assets/christmas_tree.png as the tree, but the plugin draws tree icon.png and only falls back to that file, so tree_color applies only when both are missing. The module docstring had the same after-Christmas claim." + }, { "version": "2.0.3", "released": "2026-09-28", @@ -70,7 +76,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/clock-simple/README.md b/plugins/clock-simple/README.md index be545424..e485c524 100644 --- a/plugins/clock-simple/README.md +++ b/plugins/clock-simple/README.md @@ -1,8 +1,8 @@ # Simple Clock -A clean time-and-date clock for your LED matrix. It picks the largest text that -fits your panel, shrinks and abbreviates when it has to, and lets you restyle -the time, the date and the AM/PM marker independently. +A clean time-and-date clock for your LED matrix. It centres itself on any +panel, abbreviates the weekday and date when they would not fit, and lets you +restyle the time, the date and the AM/PM marker independently. ![The clock on a 128x32 panel showing 3:07 PM in white with PM in pale yellow, and Wednesday over September 2nd in @@ -136,10 +136,11 @@ correct for the whole turn however long you make it. ### `update_interval` -Seconds between refreshes, default `1`. With `show_seconds` on you want `1`; -with it off you could raise it, but there is little to gain — the plugin only -pushes pixels to the panel when the rendered image actually changes, so an -unchanged minute costs nothing either way. +How often the LEDMatrix core calls the plugin's background `update()`, +default `1`. It has no visible effect on this plugin: the clock re-reads the +time on every frame it draws, so the display (seconds included) stays current +whatever this is set to. The plugin also only pushes pixels to the panel when +the rendered image actually changes, so an unchanged minute costs nothing. ### `timezone` @@ -148,7 +149,7 @@ An IANA timezone name such as `America/Chicago`, `Europe/London` or 1. `timezone` in this plugin's config 2. The global LEDMatrix `timezone` setting -3. The host system's timezone +3. UTC, when neither is set Leave it unset on a normal install. Set it only when you want this clock to show a *different* zone from the rest of your board — a second clock for a @@ -165,8 +166,9 @@ element simply is not used. ### `show_seconds` -Appends `:SS`, giving `3:07:09` or `15:07:09`. The string is wider, so on a -narrow panel the clock picks a smaller size to fit it. +Appends `:SS`, giving `3:07:09` or `15:07:09`. The string is wider, and the +time is not resized to fit: with a large `font_size` on a narrow panel it can +run past the edges, so pick a smaller font there. ![Four panels comparing 12-hour against 24-hour, and seconds off against on](../../docs/assets/clock-simple/time-format.png) @@ -181,8 +183,8 @@ when the hour rolls from `9:59` to `10:00`. ### `show_date` -Set to `false` for a time-only clock. The time is then drawn larger, since it -has the whole panel to itself. +Set to `false` for a time-only clock. The time stays where it is, near the top +in the same font; the space below is left empty. ### `date_format` @@ -245,8 +247,8 @@ look sharp while a `.ttf` scaled to an odd size looks soft. ## Panel Sizes and How Text Shrinks -The clock measures its text against the panel and steps down rather than -overflowing. +The clock measures the weekday and date against the panel and shortens them +rather than overflowing. The time itself is never resized. ![The same clock on 64x32, 128x32, 128x64 and 256x32 panels](../../docs/assets/clock-simple/panel-sizes.png) @@ -256,8 +258,8 @@ Two behaviours worth knowing, both visible in the 64×32 panel above: - **The weekday abbreviates.** `Wednesday` becomes `Wed` when the full name will not fit. - **The date falls back through shorter forms.** `September 2nd` becomes - `Sep 2nd`, then `Sep 2`; the numeric formats drop to a two-digit year - (`09/02/26`) and then to `09-02`. + `Sep 2nd`, then `Sep 2`; `MM/DD/YYYY` and `DD/MM/YYYY` drop to a two-digit + year (`09/02/26`), and `YYYY-MM-DD` drops to `09-02`. The clock only shortens what it must, so a wider panel keeps the full text. On a 128×64 panel the time and date sit in separate bands with the space between @@ -316,8 +318,10 @@ missing from `assets/fonts/`. ```text clock-simple/ ├── manifest.json # Plugin metadata and version history -├── manager.py # ClockSimplePlugin +├── manager.py # SimpleClock ├── config_schema.json # Settings schema; source of truth for defaults +├── test_position_and_timezone.py # Offset and timezone-fallback tests +├── test/ # Render-harness config and golden PNGs ├── README.md └── LICENSE ``` diff --git a/plugins/clock-simple/manifest.json b/plugins/clock-simple/manifest.json index c74e7d6e..529e3894 100644 --- a/plugins/clock-simple/manifest.json +++ b/plugins/clock-simple/manifest.json @@ -1,7 +1,7 @@ { "id": "clock-simple", "name": "Simple Clock", - "version": "1.1.3", + "version": "1.1.4", "author": "ChuckBuilds", "description": "A simple clock display with current time and date", "category": "time", @@ -16,6 +16,12 @@ "clock-simple" ], "versions": [ + { + "version": "1.1.4", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "README only. It said the clock picks a smaller size to fit and draws the time larger when show_date is off; neither happens -- only the weekday and date are shortened to fit, and the time keeps its font and position. Also corrects update_interval (it has no visible effect, since the time is re-read every frame), the timezone fallback when nothing is set (UTC, not the host zone), which numeric date format falls back to MM-DD, and the class name in the file listing. No change in behaviour." + }, { "version": "1.1.3", "released": "2026-09-29", @@ -73,7 +79,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/countdown/README.md b/plugins/countdown/README.md index 0b8381f7..3e70e031 100644 --- a/plugins/countdown/README.md +++ b/plugins/countdown/README.md @@ -132,12 +132,12 @@ count, and there is no separate "today" state: ![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: +Three 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. +- **Without `target_time` the target is midnight at the start of + `target_date`.** The hours-and-minutes rungs then count down through the day + *before*, and on the day itself the countdown has already passed. 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. - **The last 24 hours are drawn in yellow.** Once the target is less than a day @@ -277,7 +277,8 @@ makes the name inherit `font_family`. ### Images `image_path` may be absolute, relative to the working directory, or relative to -the plugins repository root — the plugin tries each in that order. +the LEDMatrix directory the plugin is installed under — the plugin tries each in +that order. - Square images work best; the image area is a third of the panel width by the full height. @@ -321,7 +322,7 @@ writes `Tomorrow` rather than `1 Day`. See **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 +to the working directory, then relative to the LEDMatrix directory. Also check `layout_preset` is not `text-only`, which ignores images by design. **I picked a font and nothing changed.** diff --git a/plugins/countdown/manager.py b/plugins/countdown/manager.py index c167f5e3..22e711f5 100644 --- a/plugins/countdown/manager.py +++ b/plugins/countdown/manager.py @@ -735,8 +735,12 @@ def display(self, force_clear: bool = False) -> None: # An explicit image_width/image_height from the advanced modal # still wins over both. _default_img_w = dw if layout_preset == 'image-only' else (dw // 3) - img_w = layout.get('image_width') or _default_img_w - img_h = layout.get('image_height') or dh + # Capped at the panel first: image-right places the box at + # dw - img_w, so a width wider than the panel put it at a negative + # x -- the image landed on the left, cropped, and the text area + # had a negative width, which pushed the text off the panel. + img_w = min(layout.get('image_width') or _default_img_w, dw) + img_h = min(layout.get('image_height') or dh, dh) if _has_px_override: # User set explicit pixel positions — honour them directly diff --git a/plugins/countdown/manifest.json b/plugins/countdown/manifest.json index 4feb0f31..0de772f7 100644 --- a/plugins/countdown/manifest.json +++ b/plugins/countdown/manifest.json @@ -1,7 +1,7 @@ { "id": "countdown", "name": "Countdown Display", - "version": "3.3.4", + "version": "3.3.5", "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.5", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "An image-right image with an Image Width wider than the panel stays on the panel. The box was placed at panel width minus image width, a negative x, so the image landed on the left edge, cropped, and the text area had a negative width that pushed the countdown text off the panel. The width and height are now capped at the panel before the preset places the box. README: without target_time the hours-and-minutes rungs count down through the day before the target date (the old text said they never appear), and a relative image_path is resolved against the LEDMatrix directory, not the plugins repository." + }, { "version": "3.3.4", "released": "2026-10-02", @@ -123,5 +129,5 @@ "license": "GPL-3.0", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/countdown", "config_schema": "config_schema.json", - "last_updated": "2026-09-28" + "last_updated": "2026-10-02" } diff --git a/plugins/countdown/test_layout_overrides.py b/plugins/countdown/test_layout_overrides.py index eeaef0a5..d2af9b59 100644 --- a/plugins/countdown/test_layout_overrides.py +++ b/plugins/countdown/test_layout_overrides.py @@ -160,6 +160,14 @@ def red(p): and rows and rows[-1] - rows[0] + 1 == 28, "%s rows %s" % (span, (rows[0], rows[-1]) if rows else None)) +print("an image-right width wider than the panel stays on the panel") +frame = render(layout_preset="image-right", image_path=str(RED), + layout={"image_width": 200}) +span = extent(frame, range(H), red) +check("the box is capped at the panel width, so the image is centred, not cropped", + span is not None and span[0] > 0 and span[1] < W + and abs((span[0] + span[1]) / 2 - W / 2) <= 1, str(span)) + print() print("%d failed" % len(failures)) sys.exit(1 if failures else 0) diff --git a/plugins/cricket-scoreboard/README.md b/plugins/cricket-scoreboard/README.md index 36e905b5..90364fe3 100644 --- a/plugins/cricket-scoreboard/README.md +++ b/plugins/cricket-scoreboard/README.md @@ -72,14 +72,14 @@ Configured under the `cricket-scoreboard` key in `config/config.json`. See | `show_favorite_teams_only` | `false` | Restrict *international* matches to those featuring a favorite team | | `live_game_duration` / `recent_game_duration` / `upcoming_game_duration` | 20 / 15 / 15 | Per-match on-screen seconds | | `non_favorite_live_game_duration` | `0` | Shorter turn for live matches without a favorite (0 = same as `live_game_duration`) | -| `update_interval_seconds` / `live_update_interval` | 3600 / 30 | Data refresh cadence | +| `update_interval_seconds` / `live_update_interval` | 3600 / 30 | Data refresh cadence (the live cadence also applies once an upcoming match's start time has passed, so a match that has just started is picked up within seconds) | | `series_discovery_interval` | `86400` | How often numeric series ids are re-resolved | | `recent_games_to_show` / `upcoming_games_to_show` | 5 / 5 | Match counts per mode | | `live_priority` | `true` | Live matches interrupt the normal rotation | | `display_modes` | all on | Toggle live / recent / upcoming | | `dynamic_duration`, `mode_durations` | off / null | Auto-size or cap each mode's total time | | `background_service.request_timeout` | 30 | HTTP request timeout (seconds) | -| `customization` | — | Fonts + colors for score / overs / team / status / detail text | +| `customization` | — | Fonts + colors for score / team / status / detail text | ### Every setting @@ -94,7 +94,7 @@ complete list, at the exact paths the schema expects — the schema sets | `favorite_competitions` | `["international", "ipl", "bbl"]` | Domestic competitions (keys from competitions.json) to follow. Include 'international' to follow Test/ODI/T20I tours for your favorite_teams. | | `exclude_teams` | *(empty)* | Teams to always hide from live rotation and recent/final scores (spoiler protection). Takes precedence over favorite_teams. | | `show_favorite_teams_only` | `false` | Only show matches involving a favorite national team. Domestic-league matches are always governed by favorite_competitions. | -| `display_duration` | `15` | Duration in seconds to display each match (5–60). | +| `display_duration` | `15` | Seconds a mode stays on screen when it has no matches to time by (5–60). Per-match time comes from the three durations below. | | `live_game_duration` | `20` | Duration in seconds to display each live match before rotating to the next (10–120). | | `non_favorite_live_game_duration` | `0` | Duration in seconds for live matches that do NOT involve a favorite team. 0 (default) = use live_game_duration for every live match (0–120). | | `recent_game_duration` | `15` | Duration in seconds to display each recent match (5–60). | @@ -117,15 +117,15 @@ complete list, at the exact paths the schema expects — the schema sets | `display_modes.upcoming` | `true` | Show upcoming matches. | | `background_service.request_timeout` | `30` | Timeout in seconds for each request to ESPN (5–120). | | `customization.score_text.font` | `"PressStart2P-Regular.ttf"` | one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`. | -| `customization.score_text.font_size` | `10` | (4–16). | -| `customization.period_text.font` | `"PressStart2P-Regular.ttf"` | one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`. | -| `customization.period_text.font_size` | `8` | (4–16). | +| `customization.score_text.font_size` | `8` | (4–16). | +| `customization.period_text.font` | `"PressStart2P-Regular.ttf"` | Ignored and hidden in the settings form: nothing draws with it (the overs line uses `detail_text`, the session/day `status_text`). Kept so saved configs validate. | +| `customization.period_text.font_size` | `8` | Ignored, as above. | | `customization.team_name.font` | `"PressStart2P-Regular.ttf"` | one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`. | | `customization.team_name.font_size` | `8` | (4–16). | | `customization.status_text.font` | `"4x6-font.ttf"` | one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`. | -| `customization.status_text.font_size` | `6` | (4–16). | +| `customization.status_text.font_size` | `7` | (4–16). | | `customization.detail_text.font` | `"4x6-font.ttf"` | one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`. | -| `customization.detail_text.font_size` | `6` | (4–16). | +| `customization.detail_text.font_size` | `7` | (4–16). | | `customization.colors.score_color` | `"#FFFFFF"` | Color for the runs/wickets score. | | `customization.colors.batting_color` | `"#00FF66"` | Highlight color for the team currently batting. | | `customization.colors.detail_color` | `"#FFD200"` | Color for run rate / target detail text. | @@ -211,8 +211,8 @@ live but not *whose*. Two things to keep in mind: - The weight is per **plugin**, not per game. With four games live this - scoreboard still occupies one slot at a time and picks between its own games - using `favorite_live_boost`; these weights control how often the scoreboard + scoreboard still occupies one slot at a time and rotates through its own + games, favorites first; these weights control how often the scoreboard itself comes round. - More slots make the cycle **longer**, not faster — everything else appears proportionally less often. And appearing more often only helps if the data is diff --git a/plugins/cricket-scoreboard/config_schema.json b/plugins/cricket-scoreboard/config_schema.json index e5723dfc..a2014a27 100644 --- a/plugins/cricket-scoreboard/config_schema.json +++ b/plugins/cricket-scoreboard/config_schema.json @@ -363,7 +363,8 @@ "period_text": { "type": "object", "title": "Overs / Session", - "description": "Font settings for overs, session and day text", + "x-display": "hidden", + "description": "Font settings for overs, session and day text HIDDEN: nothing draws with it. The overs line uses Details and the session/day uses Status Messages. Kept declared so saved configs keep validating.", "properties": { "font": { "type": "string", diff --git a/plugins/cricket-scoreboard/cricket_data_fetcher.py b/plugins/cricket-scoreboard/cricket_data_fetcher.py index 924c8f7c..ebd3f382 100644 --- a/plugins/cricket-scoreboard/cricket_data_fetcher.py +++ b/plugins/cricket-scoreboard/cricket_data_fetcher.py @@ -373,6 +373,10 @@ def _parse_event(self, ev: Dict[str, Any], series_id: str, series_name: str, "status_summary": status.get("summary", "") or "", "status_detail": stype.get("detail", "") or "", "status_short": stype.get("shortDetail", "") or "", + # A Test's break ("Stumps", "Lunch") and day ("Day 2"): + # shortDetail is only "Live" while one is in progress. + "status_description": stype.get("description", "") or "", + "status_session": status.get("session", "") or "", "period": int(status.get("period", 0) or 0), "date": comp.get("date") or ev.get("date") or "", "start_time_utc": self._parse_iso(comp.get("date") or ev.get("date")), @@ -419,7 +423,9 @@ def _parse_competitor(self, c: Dict[str, Any]) -> Dict[str, Any]: "short_name": team.get("shortDisplayName") or team.get("name") or "", "abbr": team.get("abbreviation") or "", "logo_url": logo_url, - "winner": bool(c.get("winner", False)), + # ESPN's cricket feed sends this as the string "true"/"false"; + # bool("false") is True, which drew the losing side green too. + "winner": str(c.get("winner", "")).strip().lower() == "true", "score_str": c.get("score", "") or "", "innings": innings, "records": records, diff --git a/plugins/cricket-scoreboard/cricket_renderer.py b/plugins/cricket-scoreboard/cricket_renderer.py index ec5f13f1..19731c8f 100644 --- a/plugins/cricket-scoreboard/cricket_renderer.py +++ b/plugins/cricket-scoreboard/cricket_renderer.py @@ -105,16 +105,17 @@ def _load_one(self, element_cfg: Dict[str, Any], default_font: str, return ImageFont.load_default() def _load_fonts(self, custom: Dict[str, Any]) -> None: + # Defaults mirror config_schema.json (score 8, status/detail 7). self._fonts["score"] = self._load_one(custom.get("score_text"), - "PressStart2P-Regular.ttf", 10) + "PressStart2P-Regular.ttf", 8) self._fonts["period"] = self._load_one(custom.get("period_text"), "PressStart2P-Regular.ttf", 8) self._fonts["team"] = self._load_one(custom.get("team_name"), "PressStart2P-Regular.ttf", 8) self._fonts["status"] = self._load_one(custom.get("status_text"), - "4x6-font.ttf", 6) + "4x6-font.ttf", 7) self._fonts["detail"] = self._load_one(custom.get("detail_text"), - "4x6-font.ttf", 6) + "4x6-font.ttf", 7) # ---- draw helpers ----------------------------------------------------- # @@ -189,15 +190,40 @@ def _draw_team_crest(self, img: Image.Image, draw: ImageDraw.ImageDraw, # ---- innings helpers -------------------------------------------------- # + @staticmethod + def _batted_innings(team: Dict[str, Any]) -> List[Dict[str, Any]]: + """The innings this side actually batted, in order. + + ESPN gives each side a linescore for every period of the match, + including the ones it spent bowling: those carry 0 runs, 0 wickets and + the opponent's overs, with isBatting false. isBatting marks the periods + a side batted in (not just the one in progress), so it stays true on a + finished first innings. Counting the bowling rows drew a Test score as + "0 & 278 & 0/0" and a side yet to bat as "0/0". + """ + return [inn for inn in (team.get("innings") or []) + if inn.get("is_batting") or inn.get("runs") or inn.get("wickets")] + @staticmethod def _batting_innings(team: Dict[str, Any]) -> Optional[Dict[str, Any]]: - innings = team.get("innings") or [] - if not innings: - return None - for inn in innings: - if inn.get("is_batting"): - return inn - return innings[-1] + """This side's latest batted innings, or None when it has not batted.""" + batted = CricketRenderer._batted_innings(team) + return batted[-1] if batted else None + + @staticmethod + def _at_crease(inn: Optional[Dict[str, Any]], + other: Optional[Dict[str, Any]]) -> bool: + """Whether `inn` is the innings in progress, given the other side's. + + Both sides keep isBatting true on every innings they batted, so the + flag alone highlighted the side that batted first for the whole chase. + The later innings (higher period) is the one in progress. + """ + if not inn: + return False + if other and inn.get("period", 0) != other.get("period", 0): + return inn.get("period", 0) > other.get("period", 0) + return bool(inn.get("is_batting")) @staticmethod def _score_text(team: Dict[str, Any]) -> str: @@ -236,8 +262,8 @@ def render_live_limited_overs(self, match: Dict[str, Any]) -> Image.Image: # Scores center. away_inn = self._batting_innings(away) home_inn = self._batting_innings(home) - away_batting = bool(away_inn and away_inn.get("is_batting")) - home_batting = bool(home_inn and home_inn.get("is_batting")) + away_batting = self._at_crease(away_inn, home_inn) + home_batting = self._at_crease(home_inn, away_inn) cx = self.width // 2 away_score = self._score_text(away) @@ -248,19 +274,43 @@ def render_live_limited_overs(self, match: Dict[str, Any]) -> Image.Image: self.batting_color if home_batting else self.score_color) # Detail line: overs + run rate for the batting side; RRR/target on chase. - detail = self._live_detail(match, away, home, away_inn, home_inn, - away_batting, home_batting) + parts = self._live_detail_parts(match, away, home, away_inn, home_inn, + away_batting, home_batting) + detail = self._fit_parts(draw, parts, self._fonts["detail"]) if detail: self._draw_centered(draw, detail, cx, self.height - 7, self._fonts["detail"], self.detail_color) return img - def _live_detail(self, match, away, home, away_inn, home_inn, - away_batting, home_batting) -> str: + def _fit_parts(self, draw: ImageDraw.ImageDraw, parts: List[str], + font: ImageFont.ImageFont) -> str: + """The detail parts joined, dropping the least useful until they fit. + + A chase reads "13/20 ov RR 9.4 need 34 RRR 4.9", about 130px in the + detail font -- wider than a 128px panel, so both ends were cut off. + Run rate goes first, then the overs; the chase equation is kept. + """ + if not parts: + return "" + candidates = [parts] + no_rr = [p for p in parts if not p.startswith("RR ")] + if no_rr != parts: + candidates.append(no_rr) + if len(no_rr) > 1: + candidates.append(no_rr[1:]) # the overs are always first + for cand in candidates: + text = " ".join(cand) + if self._text_size(draw, text, font)[0] <= self.width: + return text + return " ".join(candidates[-1]) + + def _live_detail_parts(self, match, away, home, away_inn, home_inn, + away_batting, home_batting) -> List[str]: bat_team = away if away_batting else (home if home_batting else None) bat_inn = away_inn if away_batting else (home_inn if home_batting else None) if not bat_inn: - return match.get("status_short", "") or "" + status = match.get("status_short", "") or "" + return [status] if status else [] max_overs = CricketDataFetcher.parse_max_overs( bat_team.get("score_str", ""), match.get("format", "")) @@ -283,7 +333,7 @@ def _live_detail(self, match, away, home, away_inn, home_inn, rrr = required_run_rate(runs_needed, balls_remaining) if runs_needed > 0 and rrr is not None: parts.append(f"need {runs_needed} RRR {rrr:.1f}") - return " ".join(parts) + return parts def render_live_test(self, match: Dict[str, Any]) -> Image.Image: """Live Test card: no clock -- day + session, both innings scores.""" @@ -310,7 +360,9 @@ def render_live_test(self, match: Dict[str, Any]) -> Image.Image: self._draw_centered(draw, (home.get("abbr") or "")[:4] + " " + home_score, cx, 8 + 8, self._fonts["detail"], self.score_color) - lead = match.get("status_short") or match.get("status_summary") or "" + # The summary ("India lead by 84 runs") first: ESPN's short detail + # is just "Live" while a Test is in progress. + lead = match.get("status_summary") or match.get("status_short") or "" if lead: self._draw_centered(draw, lead[:26], cx, self.height - 7, self._fonts["detail"], self.detail_color) @@ -318,7 +370,7 @@ def render_live_test(self, match: Dict[str, Any]) -> Image.Image: @staticmethod def _test_score_text(team: Dict[str, Any]) -> str: - innings = team.get("innings") or [] + innings = CricketRenderer._batted_innings(team) if not innings: return "-" pieces = [] @@ -332,16 +384,21 @@ def _test_score_text(team: Dict[str, Any]) -> str: return " & ".join(pieces) def _test_session(self, match: Dict[str, Any]) -> str: - for key in ("status_short", "status_detail", "status_summary"): + # ESPN puts the break ("Stumps", "Lunch") in status.type.description + # and the day ("Day 2") in status.session. status.period counts + # innings, not days, so it is not used here: it drew "Day 3" on the + # second day's third innings. + day = match.get("status_session") or "" + for key in ("status_description", "status_short", "status_detail", + "status_summary"): txt = match.get(key) or "" for token in ("Stumps", "Lunch", "Tea", "Close", "Drinks", "Innings Break", "Day"): if token.lower() in txt.lower(): + if day and "day" not in txt.lower(): + txt = f"{txt} {day}" return txt[:22] - period = match.get("period", 0) - if period: - return f"Day {period}" - return "Test" + return day[:22] or "Test" def render_recent(self, match: Dict[str, Any]) -> Image.Image: """Completed match: final scores + result summary.""" @@ -359,8 +416,11 @@ def render_recent(self, match: Dict[str, Any]) -> Image.Image: self._fonts["status"], self.status_color) cx = self.width // 2 - away_line = f"{(away.get('abbr') or '')[:4]} {self._final_score(away)}" - home_line = f"{(home.get('abbr') or '')[:4]} {self._final_score(home)}" + # A Test result needs both innings; the last one alone read as the + # side's whole match. + score = self._test_score_text if match.get("is_test") else self._final_score + away_line = f"{(away.get('abbr') or '')[:4]} {score(away)}" + home_line = f"{(home.get('abbr') or '')[:4]} {score(home)}" away_col = GREEN if away.get("winner") else self.score_color home_col = GREEN if home.get("winner") else self.score_color self._draw_centered(draw, away_line, cx, 7, self._fonts["detail"], away_col) diff --git a/plugins/cricket-scoreboard/manager.py b/plugins/cricket-scoreboard/manager.py index f387e986..bdd90cd7 100644 --- a/plugins/cricket-scoreboard/manager.py +++ b/plugins/cricket-scoreboard/manager.py @@ -186,8 +186,11 @@ def update(self) -> None: if not self.is_enabled: return now = time.time() - # Faster refresh when live content is present. - has_live = bool(self.live_matches) + # Faster refresh when live content is present -- or due: a match whose + # start time has passed but was still "pre" at the last fetch. Without + # this the hourly interval left a match that had just started + # unnoticed for up to an hour, so live_priority never fired for it. + has_live = bool(self.live_matches) or self._upcoming_has_started(now) interval = self.live_update_interval if has_live else self.update_interval if now - self._last_update < interval: return @@ -235,6 +238,22 @@ def update(self) -> None: self.logger.info("Cricket update: %d live, %d recent, %d upcoming", len(live), len(recent), len(upcoming)) + def _upcoming_has_started(self, now: float) -> bool: + """Whether an upcoming match's start time passed in the last 12h. + + Bounded so a fixture ESPN never moves off "pre" (abandoned without a + result) cannot hold the fast live cadence forever. + """ + with self._lock: + upcoming = list(self.upcoming_matches) + for m in upcoming: + start = m.get("start_time_utc") + if not hasattr(start, "timestamp"): + continue + if 0 <= now - start.timestamp() <= 12 * 3600: + return True + return False + def _ensure_logos(self, match: Dict[str, Any]) -> None: if download_missing_logo is None: return @@ -576,9 +595,18 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: # inside update()/display(), and replacing it would break mutual # exclusion. lock = self._lock + old_fetcher = getattr(self, "fetcher", None) self.__init__(self.plugin_id, new_config, self.display_manager, self.cache_manager, self.plugin_manager) self._lock = lock + # The rebuilt fetcher opened its own HTTP session; close the old one + # rather than leak a connection pool per settings save. + old_session = getattr(old_fetcher, "session", None) + if old_session is not None: + try: + old_session.close() + except Exception as e: + self.logger.debug("Closing the previous HTTP session failed: %s", e) # Force a re-discovery + refetch on next update. self._last_update = 0.0 diff --git a/plugins/cricket-scoreboard/manifest.json b/plugins/cricket-scoreboard/manifest.json index c6b381f8..c68f1693 100644 --- a/plugins/cricket-scoreboard/manifest.json +++ b/plugins/cricket-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "cricket-scoreboard", "name": "Cricket Scoreboard", - "version": "1.3.2", + "version": "1.3.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming cricket matches across international tours (Test/ODI/T20I) and major domestic T20 leagues including the IPL, Big Bash League, The Hundred, PSL, CPL, SA20 and more. Shows runs/wickets/overs, run rates, targets, and match results.", "category": "sports", @@ -22,6 +22,12 @@ "cricket_upcoming" ], "versions": [ + { + "version": "1.3.3", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "Scores now read ESPN's cricket data correctly. ESPN gives each side a linescore for every innings of the match, including the ones it spent bowling (0 runs, 0 wickets), and marks every innings a side batted, not only the one in progress. So a live Test drew \"0 & 278 & 0/0\" for a side that had made 278, a side yet to bat showed \"0/0\", and in a chase both sides were highlighted as batting; only innings a side batted are shown now, and only the innings in progress is highlighted. ESPN sends the winner flag as the string \"false\", which read as true, so a result drew both sides in the winner's green. A live Test shows ESPN's break and day (\"Stumps Day 2\") rather than \"Day 3\" (status.period counts innings, not days), and the bottom line shows the match situation (\"trail by 17 runs\") rather than \"Live\"; a finished Test shows both innings. The chase line drops the run rate, then the overs, when it would run off the panel. A match whose start time has passed is polled at live_update_interval, so live_priority picks it up within seconds instead of up to an hour later. Closes the old HTTP session on a settings save. customization.period_text is hidden (nothing draws with it); font size fallbacks match the schema (8/7/7). The harness fixture now seeds trimmed ESPN data, so CI renders without the network." + }, { "version": "1.3.2", "released": "2026-09-28", @@ -101,7 +107,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/cricket-scoreboard/test/fixtures/mock.json b/plugins/cricket-scoreboard/test/fixtures/mock.json new file mode 100644 index 00000000..9939d253 --- /dev/null +++ b/plugins/cricket-scoreboard/test/fixtures/mock.json @@ -0,0 +1,398 @@ +{ + "cricket:scoreboard:22547": { + "events": [ + { + "id": "1552778", + "name": "Bangladesh v Sri Lanka", + "shortName": "BAN v SL", + "date": "2026-10-03T00:00Z", + "status": { + "period": 2, + "session": "", + "summary": "Bangladesh require 106 runs", + "type": { + "description": "Live", + "detail": "Live", + "shortDetail": "Live", + "state": "in" + } + }, + "competitions": [ + { + "date": "2026-10-03T00:00Z", + "class": { + "eventType": "T20" + }, + "venue": { + "fullName": "Korogi Sports Park, Nisshin" + }, + "competitors": [ + { + "order": 2, + "homeAway": "home", + "winner": "false", + "score": "60/6 (9/20 ov, target 166)", + "team": { + "id": "25", + "name": "Bangladesh", + "displayName": "Bangladesh", + "shortDisplayName": "BAN", + "abbreviation": "BAN" + }, + "linescores": [ + { + "period": 1, + "runs": 0, + "wickets": 0, + "overs": 20.0, + "isBatting": false, + "description": "complete" + }, + { + "period": 2, + "runs": 60, + "wickets": 6, + "overs": 9.0, + "isBatting": true, + "description": "" + } + ] + }, + { + "order": 1, + "homeAway": "away", + "winner": "false", + "score": "165/9", + "team": { + "id": "8", + "name": "Sri Lanka", + "displayName": "Sri Lanka", + "shortDisplayName": "SL", + "abbreviation": "SL" + }, + "linescores": [ + { + "period": 1, + "runs": 165, + "wickets": 9, + "overs": 20.0, + "isBatting": true, + "description": "complete" + }, + { + "period": 2, + "runs": 0, + "wickets": 0, + "overs": 9.0, + "isBatting": false, + "description": "" + } + ] + } + ] + } + ] + }, + { + "id": "1552779", + "name": "India v Pakistan", + "shortName": "IND v PAK", + "date": "2026-10-03T04:30Z", + "status": { + "period": 0, + "session": null, + "summary": "Starts at 13:30 local time", + "type": { + "description": "Scheduled", + "detail": "Scheduled", + "shortDetail": "Scheduled", + "state": "pre" + } + }, + "competitions": [ + { + "date": "2026-10-03T04:30Z", + "class": { + "eventType": "T20" + }, + "venue": { + "fullName": "Korogi Sports Park, Nisshin" + }, + "competitors": [ + { + "order": 1, + "homeAway": "home", + "winner": "false", + "score": "", + "team": { + "id": "6", + "name": "India", + "displayName": "India", + "shortDisplayName": "IND", + "abbreviation": "IND" + }, + "linescores": [] + }, + { + "order": 2, + "homeAway": "away", + "winner": "false", + "score": "", + "team": { + "id": "7", + "name": "Pakistan", + "displayName": "Pakistan", + "shortDisplayName": "PAK", + "abbreviation": "PAK" + }, + "linescores": [] + } + ] + } + ] + } + ] + }, + "cricket:scoreboard:24139": { + "events": [ + { + "id": "1556694", + "name": "Vancouver Anchors v Toronto Sixers", + "shortName": "VAA v TSS", + "date": "2026-10-02T23:00Z", + "status": { + "period": 2, + "session": null, + "summary": "Anchors won by 7 wkts (2b rem)", + "type": { + "description": "Result", + "detail": "Final", + "shortDetail": "Final", + "state": "post" + } + }, + "competitions": [ + { + "date": "2026-10-02T23:00Z", + "class": { + "eventType": "ODI" + }, + "venue": { + "fullName": "BC Place, Vancouver" + }, + "competitors": [ + { + "order": 2, + "homeAway": "home", + "winner": "true", + "score": "112/3 (9.4/10 ov, target 112)", + "team": { + "id": "1506166", + "name": "Vancouver Anchors", + "displayName": "Vancouver Anchors", + "shortDisplayName": "VAA", + "abbreviation": "VAA" + }, + "linescores": [ + { + "period": 1, + "runs": 0, + "wickets": 0, + "overs": 10.0, + "isBatting": false, + "description": "" + }, + { + "period": 2, + "runs": 112, + "wickets": 3, + "overs": 9.4, + "isBatting": true, + "description": "" + } + ] + }, + { + "order": 1, + "homeAway": "away", + "winner": "false", + "score": "111/7", + "team": { + "id": "1506169", + "name": "Toronto Sixers", + "displayName": "Toronto Sixers", + "shortDisplayName": "TSS", + "abbreviation": "TSS" + }, + "linescores": [ + { + "period": 1, + "runs": 111, + "wickets": 7, + "overs": 10.0, + "isBatting": true, + "description": "" + }, + { + "period": 2, + "runs": 0, + "wickets": 0, + "overs": 9.4, + "isBatting": false, + "description": "" + } + ] + } + ] + } + ] + }, + { + "id": "1556695", + "name": "Montreal Royal Tigers v White Rock Warriors", + "shortName": "MRT v WRW", + "date": "2026-10-03T01:45Z", + "status": { + "period": 1, + "session": "", + "summary": "Tigers won toss & fielded", + "type": { + "description": "Live", + "detail": "Live", + "shortDetail": "Live", + "state": "in" + } + }, + "competitions": [ + { + "date": "2026-10-03T01:45Z", + "class": { + "eventType": "ODI" + }, + "venue": { + "fullName": "BC Place, Vancouver" + }, + "competitors": [ + { + "order": 2, + "homeAway": "home", + "winner": "false", + "score": "", + "team": { + "id": "1506164", + "name": "Montreal Royal Tigers", + "displayName": "Montreal Royal Tigers", + "shortDisplayName": "MRT", + "abbreviation": "MRT" + }, + "linescores": [ + { + "period": 1, + "runs": 0, + "wickets": 0, + "overs": 9.1, + "isBatting": false, + "description": "" + } + ] + }, + { + "order": 1, + "homeAway": "away", + "winner": "false", + "score": "133/3 (9.1/10 ov)", + "team": { + "id": "1506167", + "name": "White Rock Warriors", + "displayName": "White Rock Warriors", + "shortDisplayName": "WRW", + "abbreviation": "WRW" + }, + "linescores": [ + { + "period": 1, + "runs": 133, + "wickets": 3, + "overs": 9.1, + "isBatting": true, + "description": "" + } + ] + } + ] + } + ] + }, + { + "id": "1556696", + "name": "Brampton Blitz v Mississauga Masters", + "shortName": "BRB v MIM", + "date": "2026-10-03T05:45Z", + "status": { + "period": 0, + "session": null, + "summary": "Starts at 22:45 local time", + "type": { + "description": "Scheduled", + "detail": "Scheduled", + "shortDetail": "Scheduled", + "state": "pre" + } + }, + "competitions": [ + { + "date": "2026-10-03T05:45Z", + "class": { + "eventType": "ODI" + }, + "venue": { + "fullName": "BC Place, Vancouver" + }, + "competitors": [ + { + "order": 1, + "homeAway": "home", + "winner": "false", + "score": "", + "team": { + "id": "1506163", + "name": "Brampton Blitz", + "displayName": "Brampton Blitz", + "shortDisplayName": "BRB", + "abbreviation": "BRB" + }, + "linescores": [] + }, + { + "order": 2, + "homeAway": "away", + "winner": "false", + "score": "", + "team": { + "id": "1506165", + "name": "Mississauga Masters", + "displayName": "Mississauga Masters", + "shortDisplayName": "MIM", + "abbreviation": "MIM" + }, + "linescores": [] + } + ] + } + ] + } + ] + }, + "cricket:series_ids:bbl|international|ipl|australia|england|india": [ + { + "series_id": "22547", + "name": "Asian Games Men's Cricket Competition", + "competition_key": "international" + }, + { + "series_id": "24139", + "name": "Canada Super60", + "competition_key": "international" + } + ] +} \ No newline at end of file diff --git a/plugins/cricket-scoreboard/test/harness.json b/plugins/cricket-scoreboard/test/harness.json index b6f44f63..4d334100 100644 --- a/plugins/cricket-scoreboard/test/harness.json +++ b/plugins/cricket-scoreboard/test/harness.json @@ -1,5 +1,5 @@ { - "_comment": "Deterministic harness config + mocked ESPN cricket responses for golden-image rendering. mock_matches is consumed directly by test_cricket_plugin.py (the plugin's live fetcher is bypassed) and covers every format branch: a live T20 chase, a live Test with a session, a completed match, and an upcoming fixture.", + "_comment": "Deterministic harness config + mocked ESPN cricket responses. mock_data (test/fixtures/mock.json) seeds the plugin cache with a resolved series list and trimmed real ESPN scoreboards (a live T20 chase, a live Super60 innings, a result and two fixtures), so the core harness renders without touching the network. mock_matches is consumed directly by test_cricket_plugin.py (the plugin's live fetcher is bypassed) and covers every format branch: a live T20 chase, a live Test with a session, a completed match, and an upcoming fixture.", "config": { "enabled": true, "favorite_teams": ["India", "Australia", "England"], @@ -14,7 +14,8 @@ "show_venue": true, "display_modes": {"live": true, "recent": true, "upcoming": true} }, - "freeze_time": "2026-05-31 15:30:00", + "mock_data": "test/fixtures/mock.json", + "freeze_time": "2026-10-03 02:00:00", "mock_matches": [ { "id": "live-t20", diff --git a/plugins/f1-scoreboard/README.md b/plugins/f1-scoreboard/README.md index cccb5e89..d09d969d 100644 --- a/plugins/f1-scoreboard/README.md +++ b/plugins/f1-scoreboard/README.md @@ -102,7 +102,7 @@ Each mode section has an `enabled` toggle and mode-specific options: | `qualifying` | `show_team_duel` | `true` | Show team H2H summary card (who outqualified their teammate) | | `practice` | `sessions_to_show` | `["FP1","FP2","FP3"]` | Which sessions to render | | `practice` | `top_n` | `10` | Drivers per practice session | -| `sprint` | `top_finishers` | `10` | Sprint result depth | +| `sprint` | `top_finishers` | `10` | Sprint result depth (3–22) | | `calendar` | `max_events` | `5` | Race weekends to show | | `calendar` | `show_practice` | `false` | Include practice sessions in calendar | | `calendar` | `show_qualifying` | `true` | Include qualifying in calendar | @@ -196,12 +196,18 @@ Override the font used for each text role. All fonts are bundled in `assets/font | Key | Default font | Description | |---|---|---| -| `customization.header_text.font` | `PressStart2P-Regular.ttf` | Section headers and GP names | -| `customization.position_text.font` | `PressStart2P-Regular.ttf` | Position numbers and driver codes | -| `customization.detail_text.font` | `4x6-font.ttf` | Points, times, gaps | -| `customization.small_text.font` | `4x6-font.ttf` | Secondary info (circuit name, location) | +| `customization.header_text.font` | `""` (auto) | Section headers and GP names | +| `customization.position_text.font` | `""` (auto) | Position numbers and driver codes | +| `customization.detail_text.font` | `""` (auto) | Points, times, gaps | +| `customization.small_text.font` | `""` (auto) | Secondary info (circuit name, location) | -Available fonts: `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf` +Left blank, each role gets a crisp bitmap font picked for your panel size +(`6x10.bdf` / `4x6.bdf` on 128x32, larger BDFs on bigger panels). + +Available fonts: `4x6.bdf`, `5x8.bdf`, `6x10.bdf`, `7x13.bdf`, `9x15.bdf`, +`10x20.bdf` (bitmap fonts, always drawn at their native size, so `font_size` +does not apply), and the scalable `PressStart2P-Regular.ttf`, `4x6-font.ttf`, +`5by7.regular.ttf`. Each role also takes a size, and `auto_scale` decides whether those sizes are used as written or scaled to the panel: @@ -209,10 +215,10 @@ used as written or scaled to the panel: | Key | Default | Description | |---|---|---| | `customization.auto_scale` | `true` | Leave the font fields below blank to auto-pick a crisp pixel (bitmap) font sized for your panel. This toggle only affects a scalable TTF you choose manually: on, it scales that TTF with panel size and snaps it to its pixel grid; off, it uses the exact size below. | -| `customization.header_text.font_size` | `8` | Size in pixels. | -| `customization.position_text.font_size` | `8` | Size in pixels. | -| `customization.detail_text.font_size` | `6` | Size in pixels. | -| `customization.small_text.font_size` | `6` | Size in pixels. | +| `customization.header_text.font_size` | `8` | Size in pixels (4–16; TTF fonts only). | +| `customization.position_text.font_size` | `8` | Size in pixels (4–16; TTF fonts only). | +| `customization.detail_text.font_size` | `6` | Size in pixels (4–16; TTF fonts only). | +| `customization.small_text.font_size` | `6` | Size in pixels (4–16; TTF fonts only). | ### Driver and team codes diff --git a/plugins/f1-scoreboard/f1_renderer.py b/plugins/f1-scoreboard/f1_renderer.py index d1185c29..b048380d 100644 --- a/plugins/f1-scoreboard/f1_renderer.py +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -274,13 +274,19 @@ def _load_fonts(self) -> Dict[str, Any]: user_size = c.get("font_size") if font_name in _BDF_NATIVE_SIZE: size = _BDF_NATIVE_SIZE[font_name] # bitmap: always native px - elif user_size: - size = int(user_size) - else: - base = 8 if key in ("header", "position") else 6 + elif auto_scale: + # The web UI saves font_size with every config, so it is the + # base here rather than a bypass: auto_scale on scales it with + # the panel and snaps it to the font's pixel grid. + base = int(user_size) if user_size else ( + 8 if key in ("header", "position") else 6) floor = 6 if key in ("header", "position") else 5 target = max(floor, int(base * type_scale)) - size = _snap_font_size(font_name, target) if auto_scale else target + size = _snap_font_size(font_name, target) + else: + # auto_scale off: the exact size set. + size = int(user_size) if user_size else ( + 8 if key in ("header", "position") else 6) fonts[key] = self._load_font(font_name, int(size)) return fonts diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 399d51cf..6d478626 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.10.0", + "version": "1.10.1", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -29,6 +29,12 @@ "f1_calendar" ], "versions": [ + { + "version": "1.10.1", + "released": "2026-10-02", + "ledmatrix_min_version": "3.6.1", + "notes": "customization.auto_scale now does what it says for a TTF font you pick: on, the font size is scaled with the panel and snapped to the font's pixel grid; off, the exact size is used. The web UI saves font_size with every config, and a set font_size bypassed auto_scale entirely, while with no font_size auto_scale off still scaled. Blank font fields (the default bitmap fonts) are unchanged. README: the font table listed PressStart2P/4x6-font as defaults (the default is blank, auto-picked BDF) and omitted the six bundled BDF fonts; sprint.top_finishers range added." + }, { "version": "1.10.0", "released": "2026-09-29", @@ -352,7 +358,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index 5e1a31bc..d70e5839 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.18.5] - 2026-10-02 + +### Documentation +- README: the per-league `odds_update_interval` (`3600` s) and + `live_odds_update_interval` (`60` s) settings are documented in the + Update intervals table. No change in behaviour. + ## [3.18.4] - 2026-10-02 ### Changed diff --git a/plugins/football-scoreboard/README.md b/plugins/football-scoreboard/README.md index 89cd000e..01fa4240 100644 --- a/plugins/football-scoreboard/README.md +++ b/plugins/football-scoreboard/README.md @@ -518,6 +518,8 @@ All **Advanced**. | `.recent_update_interval` | 60–86400 s | `3600` | How often the finished-games list is rebuilt. This also sets how soon a game that has just ended can appear — lower it if you want results sooner. | | `.upcoming_update_interval` | 60–86400 s | `3600` | How often the upcoming-games list is rebuilt. Selection and the non-favorite rotation both run on the display side, so this governs only the fetch. | | `.stale_game_timeout` | 60–3600 s | `300` | Drop a live game the API has stopped updating. | +| `.odds_update_interval` | 60–86400 s | `3600` | How often odds are re-fetched for recent and upcoming games. Only used when `show_odds` is on. | +| `.live_odds_update_interval` | 15–3600 s | `60` | How often odds are re-fetched for a game in progress. Only used when `show_odds` is on. | ### Display options diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index e74b4fbc..d029cc13 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "3.18.4", + "version": "3.18.5", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.18.5", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "README only: the per-league odds_update_interval (default 3600 s) and live_odds_update_interval (default 60 s) settings are now documented in the Update intervals table. No change in behaviour." + }, { "version": "3.18.4", "released": "2026-10-02", diff --git a/plugins/hello-world/README.md b/plugins/hello-world/README.md index c862fb7e..a6a2bd56 100644 --- a/plugins/hello-world/README.md +++ b/plugins/hello-world/README.md @@ -105,14 +105,17 @@ thirds. With it off, the message alone is drawn on the centre line. The clock shows the LEDMatrix timezone from the main settings, falling back to the Pi's system time when that setting is missing or invalid. -**The message is not shrunk or wrapped.** It is drawn centred at whatever size -the font gives, so a message wider than the panel is clipped at both ends: +**The message is shrunk to fit, never wrapped.** Each line starts at its +registered font size (10 px for the message, 8 px for the clock) and steps down +a pixel at a time until it fits the panel width, stopping at 4 px. A message +still too wide at that size, or any message on an install without the core font +manager (the bundled 6x9 BDF is used unshrunk there), is clipped at both ends: ![Hi, Hello World! and a message too long to fit, on a 128x32 panel](../../docs/assets/hello-world/message-length.png) -The default `Hello, World!` just fits a 128-wide panel. On a 64-wide panel it -does not — keep the message short, or use the +The default `Hello, World!` fits a 128-wide panel at full size; on a 64-wide +panel it is drawn in a much smaller font. Keep the message short, or use the [Scrolling Text](../text-display/) plugin, which scrolls and can auto-size. ### Colours @@ -165,8 +168,8 @@ differ so the two lines read as separate things at a glance. panels](../../docs/assets/hello-world/panel-sizes.png) Text is centred horizontally and placed by fractions of the panel height, so -the layout holds at any size. The only real constraint is message width — see -above. +the layout holds at any size. The only real constraint is message width: a +narrow panel shrinks the font (see above). --- @@ -227,7 +230,8 @@ case-sensitive, no spaces. This is the single most common mistake when copying this plugin to start a new one. **The message is cut off at both ends.** -It is wider than the panel. The plugin centres but does not resize or wrap; use +It is wider than the panel even at the smallest font size the plugin steps +down to, or the core font manager is unavailable. The plugin does not wrap; use a shorter message or a wider panel. **Colours look wrong.** @@ -246,7 +250,8 @@ hello-world/ ├── manager.py # HelloWorldPlugin ├── config_schema.json # Settings schema; source of truth for defaults ├── example_config.json # A config block to copy -├── requirements.txt # None beyond the core +├── requirements.txt # freetype-py, which the core already installs +├── test_timezone.py # Regression test: the clock follows the LEDMatrix timezone ├── QUICK_START.md # Enabling it and verifying it on a Pi └── README.md ``` diff --git a/plugins/hello-world/manager.py b/plugins/hello-world/manager.py index 6ecf4d3b..01b94c81 100644 --- a/plugins/hello-world/manager.py +++ b/plugins/hello-world/manager.py @@ -77,11 +77,12 @@ def on_config_change(self, new_config): def _register_fonts(self): """Register fonts with the font manager.""" try: - if not hasattr(self.plugin_manager, 'font_manager'): + # The core always sets the attribute but passes None when it has + # no font manager, so hasattr() alone is not enough. + font_manager = getattr(self.plugin_manager, 'font_manager', None) + if font_manager is None: return - font_manager = self.plugin_manager.font_manager - # Message font font_manager.register_manager_font( manager_id=self.plugin_id, @@ -217,8 +218,8 @@ def display(self, force_clear=False): time_font = None try: - if hasattr(self.plugin_manager, 'font_manager'): - font_manager = self.plugin_manager.font_manager + font_manager = getattr(self.plugin_manager, 'font_manager', None) + if font_manager is not None: # resolve_font() is the accessor for a registered element -- # it honours user overrides and takes the same (family, # size_px) the element was registered with. get_font() is @@ -293,7 +294,8 @@ def display(self, force_clear=False): x=width // 2, y=height // 2, color=(255, 0, 0), - font=self.bdf_font + font=self.bdf_font, + centered=True ) self.display_manager.update_display() except Exception: diff --git a/plugins/hello-world/manifest.json b/plugins/hello-world/manifest.json index 5e9dfef9..482c93ba 100644 --- a/plugins/hello-world/manifest.json +++ b/plugins/hello-world/manifest.json @@ -1,7 +1,7 @@ { "id": "hello-world", "name": "Hello World", - "version": "1.1.3", + "version": "1.1.4", "author": "ChuckBuilds", "description": "A simple test plugin that displays a customizable message", "entry_point": "manager.py", @@ -16,6 +16,12 @@ "hello-world" ], "versions": [ + { + "version": "1.1.4", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "A core that has no font manager (the attribute set to None) no longer logs a font warning on every frame: the plugin checked hasattr(), which is true for None, then called resolve_font on None. The error screen's text is centred instead of starting mid-panel and running off the right edge. README: the message is shrunk to fit the panel width (down to 4 px) when the core font manager is present, not clipped at full size; the file list no longer says requirements.txt is empty (it lists freetype-py)." + }, { "version": "1.1.3", "released": "2026-09-28", @@ -55,7 +61,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/hockey-scoreboard/CHANGELOG.md b/plugins/hockey-scoreboard/CHANGELOG.md index 09210e0c..90e32e27 100644 --- a/plugins/hockey-scoreboard/CHANGELOG.md +++ b/plugins/hockey-scoreboard/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.42.2] - 2026-10-02 + +### Documentation +- README: documents `.update_intervals.live_odds` (`60` s) and the + full-screen `scroll_card.switch_show_date` / `switch_show_time` switches, + and says `show_date` / `show_time` apply to the scroll and Vegas cards. +- `requirements.txt`: dropped a stale note about a `base_classes.py` the + plugin no longer ships. No change in behaviour. + ## [1.42.1] - 2026-10-02 ### Changed diff --git a/plugins/hockey-scoreboard/README.md b/plugins/hockey-scoreboard/README.md index a6691018..391067e3 100644 --- a/plugins/hockey-scoreboard/README.md +++ b/plugins/hockey-scoreboard/README.md @@ -532,6 +532,7 @@ All **Advanced**. | `.update_intervals.recent` | 60–86400 s | `3600` | Refresh for finished games. | | `.update_intervals.upcoming` | 60–86400 s | `3600` | Refresh for the schedule. | | `.update_intervals.odds` | 60–86400 s | `3600` | Refresh for betting odds. | +| `.update_intervals.live_odds` | 30–3600 s | `60` | Refresh for betting odds while a game is live. | | `.update_intervals.stale_game_timeout` | 60–3600 s | `300` | Drop a live game the API has stopped updating. | ### Display durations @@ -617,7 +618,8 @@ Plugin-wide, not per league. | Date Format | `scroll_card.date_format` | `abbrev` | Scroll and Vegas cards: `Sep 19`, `9/19`, `19 Sep`, `19/9`, or `Fri Sep 19`. | | Full-Screen Date Format | `scroll_card.switch_date_format` | `numeric` | **Advanced.** The same for the full-screen scoreboard, plus `inherit`. It has its own default because the two displays disagree about what is normal. | | Time Format | `scroll_card.time_format` | `12h` | 12- or 24-hour clock. | -| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line. | +| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line from the scroll and Vegas cards. | +| Full-Screen Show Date / Show Time | `scroll_card.switch_show_date`, `scroll_card.switch_show_time` | `true` | The same for the full-screen upcoming scoreboard. Separate switches because Show Date / Show Time predate that display reading the block. | | Full-Screen Recent Date | `scroll_card.switch_recent_show_date` | `true` | Draw the date a finished game was played along the bottom of the full-screen recent scoreboard, written in the Full-Screen Date Format. | | Swap Date and Time | `scroll_card.swap_date_time` | `false` | Flip the two lines. Each display starts from its own order, so this flips rather than forces. | diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index a814f355..3e42c2e2 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.42.1", + "version": "1.42.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.42.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "README only: documents update_intervals.live_odds (default 60 s) and the full-screen scroll_card.switch_show_date / switch_show_time switches, and says Show Date / Show Time apply to the scroll and Vegas cards. requirements.txt drops a stale note about a base_classes.py the plugin no longer ships. No change in behaviour." + }, { "version": "1.42.1", "released": "2026-10-02", diff --git a/plugins/hockey-scoreboard/requirements.txt b/plugins/hockey-scoreboard/requirements.txt index 3b846ac4..8fdee5fc 100644 --- a/plugins/hockey-scoreboard/requirements.txt +++ b/plugins/hockey-scoreboard/requirements.txt @@ -18,6 +18,3 @@ urllib3>=2.7.0 # - src.cache_manager (CacheManager) # - src.display_manager (DisplayManager) -# Note: The plugin now includes its own base classes in base_classes.py -# to be self-contained and not depend on the main LEDMatrix installation - diff --git a/plugins/incoming-packages/README.md b/plugins/incoming-packages/README.md index 4f428a1f..543ed9e7 100644 --- a/plugins/incoming-packages/README.md +++ b/plugins/incoming-packages/README.md @@ -134,7 +134,7 @@ under `incoming-packages`. The full schema is | `include_delivered` | `false` | Also give already-delivered packages their own card | | `show_usps_mail_image` | `true` | Include the USPS Informed Delivery mail card when there is mail | | `show_delivery_images` | `true` | Show a carrier's scanned delivery photo when it is out for delivery today | -| `highlight_today` | `true` | Sort arriving-today carriers first and accent their count | +| `highlight_today` | `true` | Give the arriving-today count its own line in `accent_color`. Off drops that line from the count and summary cards. Carriers arriving today are sorted first either way | | `accent_color` | `[0, 220, 120]` | The accent colour used for that highlight | | `customization.title_text.text_color` | `[255, 255, 255]` | Colour of the primary text | @@ -161,7 +161,7 @@ compact summary: ![show_dashboard true and false](../../docs/assets/incoming-packages/show-dashboard.png) -`highlight_today` controls both the accent colour and the sort order: +`highlight_today` decides whether the arriving-today count gets its own accented line. With it off the card falls back to the in-transit count; the sort order does not change: ![highlight_today true and false](../../docs/assets/incoming-packages/highlight-today.png) diff --git a/plugins/incoming-packages/manifest.json b/plugins/incoming-packages/manifest.json index 697610ba..980dac68 100644 --- a/plugins/incoming-packages/manifest.json +++ b/plugins/incoming-packages/manifest.json @@ -1,7 +1,7 @@ { "id": "incoming-packages", "name": "Incoming Packages", - "version": "1.1.3", + "version": "1.1.4", "author": "ChuckBuilds", "description": "Rotating cards for packages headed your way - per carrier, with the count arriving today highlighted. Reads a normalized snapshot from a pluggable provider: the Home Assistant Mail and Packages integration (default), AfterShip, or a built-in demo. Email scanning stays inside Home Assistant; the plugin only stores an API URL + token.", "category": "productivity", @@ -27,6 +27,12 @@ "pillow" ], "versions": [ + { + "released": "2026-10-02", + "version": "1.1.4", + "changelog": "README only: highlight_today does not change the sort order (arriving-today carriers always sort first); turning it off drops the accented arriving-today line.", + "ledmatrix_min_version": "3.4.0" + }, { "released": "2026-09-28", "version": "1.1.3", @@ -76,7 +82,7 @@ "notes": "Initial release. Provider-agnostic incoming-package display with a Home Assistant Mail and Packages provider (default, auto-discovers sensor.mail_* entities over the HA REST API), an AfterShip provider, and a built-in demo provider. Size-adaptive rotating cards: one per active carrier with a drawn carrier badge, the count arriving today (prioritized and shown in an accent color), the count in transit, and a lead summary card, plus optional USPS mail count. Renders on all panel sizes from 64x32 to 256x64 with adaptive font tiers and marquee text; on-panel setup/error messages." } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/jellyfin-now-playing/CHANGELOG.md b/plugins/jellyfin-now-playing/CHANGELOG.md index d08b0975..f13e83d5 100644 --- a/plugins/jellyfin-now-playing/CHANGELOG.md +++ b/plugins/jellyfin-now-playing/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.3.3] - 2026-10-02 + +### Fixed +- The subtitle font size falls back to 7 when the setting is absent: the + schema default, and the 4x6 font's crisp size. The code fell back to 6, + which drops glyph columns. The README listed 6 as the default; it says 7. + ## [1.3.2] - 2026-09-28 ### Fixed diff --git a/plugins/jellyfin-now-playing/README.md b/plugins/jellyfin-now-playing/README.md index 9323bc63..9c1a730b 100644 --- a/plugins/jellyfin-now-playing/README.md +++ b/plugins/jellyfin-now-playing/README.md @@ -75,7 +75,7 @@ Customization** in the web UI. | `customization.title_text.font_size` | `7` | Title height in pixels, 4–16 (advanced) | | `customization.title_text.text_color` | `[255, 255, 255]` | Title color | | `customization.subtitle_text.font` | `4x6-font.ttf` | Font for the subtitle and the time readout (advanced) | -| `customization.subtitle_text.font_size` | `6` | Subtitle height in pixels, 4–16 (advanced) | +| `customization.subtitle_text.font_size` | `7` | Subtitle height in pixels, 4–16 (advanced) | | `customization.subtitle_text.text_color` | `[170, 170, 170]` | Subtitle color | | `customization.progress_bar.bar_color` | `[124, 77, 255]` | Filled portion of the bar. Ignored while paused, when the bar is amber | | `customization.progress_bar.background_color` | `[40, 40, 40]` | Unfilled portion of the bar | diff --git a/plugins/jellyfin-now-playing/manager.py b/plugins/jellyfin-now-playing/manager.py index afcb7073..174dfc75 100644 --- a/plugins/jellyfin-now-playing/manager.py +++ b/plugins/jellyfin-now-playing/manager.py @@ -90,7 +90,7 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.title_font = self._load_font(title_text.get('font', '5by7.regular.ttf'), int(title_text.get('font_size', 7))) self.subtitle_font = self._load_font(subtitle_text.get('font', '4x6-font.ttf'), - int(subtitle_text.get('font_size', 6))) + int(subtitle_text.get('font_size', 7))) # State self.now_playing: Optional[Dict[str, Any]] = None diff --git a/plugins/jellyfin-now-playing/manifest.json b/plugins/jellyfin-now-playing/manifest.json index 8fd6c25c..757425e7 100644 --- a/plugins/jellyfin-now-playing/manifest.json +++ b/plugins/jellyfin-now-playing/manifest.json @@ -1,7 +1,7 @@ { "id": "jellyfin-now-playing", "name": "Jellyfin Now Playing", - "version": "1.3.2", + "version": "1.3.3", "author": "ChuckBuilds", "description": "Shows what's playing on your Jellyfin server: poster art, title, and playback progress", "category": "media", @@ -26,6 +26,12 @@ "pillow" ], "versions": [ + { + "released": "2026-10-02", + "version": "1.3.3", + "changelog": "The subtitle font size falls back to 7, the schema default and the 4x6 font's crisp size, when the setting is absent; the code fell back to 6, which drops glyph columns. README default corrected to match.", + "ledmatrix_min_version": "3.4.0" + }, { "released": "2026-09-28", "version": "1.3.2", @@ -87,7 +93,7 @@ "notes": "Initial release: polls the Jellyfin /Sessions API and shows the active session's poster (series poster for TV episodes), scrolling title and subtitle, a paused indicator, and a progress bar that moves smoothly between polls. Includes username and content-type filters, a Nothing Playing screen, and on-panel setup/error messages on all four panel sizes." } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/lacrosse-scoreboard/CHANGELOG.md b/plugins/lacrosse-scoreboard/CHANGELOG.md index a8510e90..a88882c5 100644 --- a/plugins/lacrosse-scoreboard/CHANGELOG.md +++ b/plugins/lacrosse-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.36.3] - 2026-10-02 + +### Changed +- `defaults.game_display_duration` is hidden: nothing reads it (per-game time + comes from each league's `display_durations`; the value only appeared in the + plugin's status info). Kept declared so saved configs keep validating. +- README documents `defaults.game_display_duration`, + `.update_intervals.live_odds` and `scroll_card.switch_show_date` / + `switch_show_time`. + ## [1.36.2] - 2026-10-02 ### Changed diff --git a/plugins/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md index 25c7e366..5fc96fc7 100644 --- a/plugins/lacrosse-scoreboard/README.md +++ b/plugins/lacrosse-scoreboard/README.md @@ -238,6 +238,7 @@ Fallbacks used when the corresponding per-league setting is absent. | Key | Type | Default | What it does | |---|---|---|---| | `defaults.display_duration` | 5–60 s | `15` | Per-game on-screen time. | +| `defaults.game_display_duration` | 3–60 s | `15` | **Hidden (still declared); has no effect.** Per-game time comes from each league's `display_durations`. | | `defaults.show_records` | boolean | `false` | Draw win-loss records. | | `defaults.show_ranking` | boolean | `false` | Draw poll rank badges. | | `defaults.show_odds` | boolean | `false` | **Advanced.** Draw betting odds. | @@ -311,6 +312,7 @@ All **Advanced**. | `.update_intervals.recent` | 60–86400 s | `3600` | Refresh for finished games. | | `.update_intervals.upcoming` | 60–86400 s | `3600` | Refresh for the schedule. | | `.update_intervals.odds` | 60–86400 s | `3600` | Refresh for betting odds. | +| `.update_intervals.live_odds` | 30–3600 s | `60` | **Advanced.** Refresh for betting odds while a game is live. | | `.update_intervals.stale_game_timeout` | 60–3600 s | `300` | Drop a live game the API has stopped updating. | ### Display durations @@ -427,7 +429,8 @@ These settings are plugin-wide, not per league, and apply to every display mode. | Date Format | `scroll_card.date_format` | `abbrev` | Scroll and Vegas cards: `Sep 19`, `9/19`, `19 Sep`, `19/9`, or `Fri Sep 19`. | | Full-Screen Date Format | `scroll_card.switch_date_format` | `numeric` | **Advanced.** The same for the full-screen scoreboard, plus `inherit`. It has its own default because the two displays disagree about what is normal. | | Time Format | `scroll_card.time_format` | `12h` | 12- or 24-hour clock. | -| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line. | +| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line from the scroll and Vegas cards. | +| Full-Screen Show Date / Show Time | `scroll_card.switch_show_date`, `scroll_card.switch_show_time` | `true` | The same for the full-screen scoreboard. Separate switches because the originals predate this display reading the block, and sharing them would have changed what existing boards draw. | | Full-Screen Recent Date | `scroll_card.switch_recent_show_date` | `true` | Draw the date a finished game was played along the bottom of the full-screen recent scoreboard, written in the Full-Screen Date Format. | | Swap Date and Time | `scroll_card.swap_date_time` | `false` | Flip the two lines. Each display starts from its own order, so this flips rather than forces. | diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index a4f82310..7550470f 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -217,8 +217,9 @@ "default": 15, "minimum": 3, "maximum": 60, - "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode", - "x-advanced": true + "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode HIDDEN: nothing reads it. Per-game time comes from each league's display_durations (live/recent/upcoming); this value only appears in the plugin's status info. Kept declared so saved configs keep validating.", + "x-advanced": true, + "x-display": "hidden" }, "show_records": { "type": "boolean", diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 38213534..b3750197 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.36.2", + "version": "1.36.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.36.3", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "Hides defaults.game_display_duration, which nothing reads (per-game time comes from each league's display_durations). README documents it, update_intervals.live_odds and scroll_card.switch_show_date / switch_show_time. No change in behaviour." + }, { "version": "1.36.2", "released": "2026-10-02", diff --git a/plugins/ledmatrix-elections/README.md b/plugins/ledmatrix-elections/README.md index 8f08813c..e1018b1f 100644 --- a/plugins/ledmatrix-elections/README.md +++ b/plugins/ledmatrix-elections/README.md @@ -106,9 +106,10 @@ testing or to point at one specific feed: | `only_my_state` | `true` | National + your state's races only. | | `race_types` | `["president","senate","governor","house","ballot"]` | Office types to show. | | `update_interval` | `60` | Poll seconds. Use 30–60 on election night. | -| `display_duration` | `30` | Ticker seconds per rotation (dynamic duration may extend). | +| `display_duration` | `30` | Fallback only. While races are loaded the ticker stays for exactly one pass over every race (at least 6 s), however long that is; a duration set for the mode on the web UI's rotation page overrides both. | | `hide_called_after_seconds` | `86400` | Drop a called race from the ticker this long after it was called (0 disables). | | `live_priority` | `true` | Allow the called-race interrupt. | +| `interrupt.enabled` | `true` | Interrupt with the full-screen card when a race is newly called. Off: a new call no longer takes over the screen. | | `interrupt.duration_seconds` | `12` | How long each called card holds the screen. | | `interrupt.my_state_only` | `true` | Only interrupt for races passing the state filter. | | `interrupt.max_age_seconds` | `300` | Drop stale queued calls older than this. | @@ -119,7 +120,7 @@ testing or to point at one specific feed: | `override.feed_filename` | `""` | Exact NYT filename for a non-standard slug (specials). | | `override.feed_url` | `""` | Full URL of a results JSON; bypasses URL building entirely. | | `override.trail_days` | `14` | Days after election day to keep showing results. | -| `calendar_events` | `[]` | Extra scheduled elections (e.g. your state's primary). Each: `{state,date,type,feed_filename?,trail_days?}`. | +| `calendar_events` | `[]` | Extra scheduled elections (e.g. your state's primary). Each: `{state,date,type,feed_filename?,trail_days?}`; `feed_url` is also read, by hand-editing `config.json`. `date` must be `YYYY-MM-DD`; an entry that is not is skipped with a warning. | | `local_races` | `true` | Use your state's authoritative local results source where one exists (auto-engages by state; currently CA). | | `test_mode` | `false` | Render bundled fixtures offline (demo / no election night). | @@ -129,24 +130,26 @@ These are real settings the table above left out. | Key | Default | Notes | |---|---|---| -| `lower_chamber_district` | `""` | Your state legislature lower-chamber district number — whatever your state calls it (Assembly, House of Delegates). Set it to have that local race appear in the ticker; leave blank to omit it. | -| `upper_chamber_district` | `""` | Your state senate district number. Same idea. | +| `lower_chamber_district` | `""` | Your state legislature lower-chamber district number — whatever your state calls it (Assembly, House of Delegates). Set it to have that local race appear in the ticker; leave blank to omit it. `assembly_district` is read as an alias. | +| `upper_chamber_district` | `""` | Your state senate district number. Same idea. `senate_district` is read as an alias. | | `scroll_speed` | `1.0` | Pixels moved per scroll step. The ticker runs at `scroll_speed / scroll_delay` pixels per second (default 33.3), moved by the LEDMatrix core to the nearest speed the panel can draw in whole pixels. | | `scroll_delay` | `0.03` | Seconds per scroll step; lower is faster. Around `0.03` reads comfortably, `0.01` is brisk. | | `providers.nyt.enabled` | `true` | Use the NYT static feed as the national baseline. | | `providers.nyt.base_url` | `https://static01.nyt.com/elections-assets/pages/data` | Where the NYT feeds are fetched from. Change it only to point at a mirror or a local capture. | | `providers.nyt.election_date` | `2026-06-02` | Drives the feed URL. Normally set for you by the calendar or `override` — see the note below. | -| `providers.nyt.election_type` | `primary` | `primary` feeds are per-state; `general` feeds are national. Also normally set for you. | +| `providers.nyt.election_type` | `primary` | Picks the feed name: `results-{state}-primary.json` for `primary`, `results-{state}.json` for `general` (both per-state). Also normally set for you. | | `providers.ca_sos.base_url` | `https://api.sos.ca.gov/returns` | California Secretary of State returns API. | -| `providers.ca_sos.offices` | `[]` | Statewide office slugs to fetch from the SoS (e.g. `governor`, `attorney-general`). Empty uses the built-in set. | +| `providers.ca_sos.offices` | `[]` | Statewide office slugs to fetch from the SoS (e.g. `governor`, `attorney-general`). Empty fetches none. | | `providers.ca_sos.include_house` | `false` | Also pull CA U.S. House results from the SoS. Off by default — the national feed already covers them. | | `providers.ca_sos.called_threshold` | `99.0` | Reporting percentage at which an SoS office counts as called, since the SoS feed carries no winner flag. | -> **Two settings the code reads are not in the schema**, so they never appear in -> the web UI and can only be set by hand-editing `config.json`: -> `test_mode` (default `false`, used above to render bundled fixtures offline) -> and `providers.ca_sos.override_nyt_votes` (default `true`, described under -> California local results). Both work; they are simply not offered by the form. +> **Three settings the code reads are not in the schema**, so they never appear +> in the web UI and can only be set by hand-editing `config.json`: +> `test_mode` (default `false`, used above to render bundled fixtures offline), +> `providers.ca_sos.override_nyt_votes` (default `true`, described under +> California local results) and `providers.ca_sos.advance_count` (default `2`, +> how many leading candidates count as advancing once an SoS race is called). +> All work; they are simply not offered by the form. > The active election's date and type are chosen by the calendar/override and > pushed to the NYT provider automatically; you don't set `providers.nyt.election_date` @@ -154,10 +157,13 @@ These are real settings the table above left out. ### California local results -For California users this **engages automatically** (`local_races` is on by -default) — there's no provider to switch on. It supplements CA races with the -authoritative Secretary of State tally and an optional **county/city rollup**. -The knobs under `providers.ca_sos` only fine-tune that rollup: +For California users the Secretary of State source **engages automatically** +(`local_races` is on by default) — there's no provider to switch on. Out of the +box it fetches nothing, though: the NYT feed already carries CA's statewide, +U.S. House and legislature races with a better reporting estimate. It only +contributes once you list `providers.ca_sos.offices` or turn on +`providers.ca_sos.include_house`; it then supplements those CA races with the +authoritative SoS tally, optionally as a **county/city rollup**: - `county` — a county slug (e.g. `los-angeles`) for county-level totals. - `city` — a major CA city, mapped to its county (ignored if `county` is set). @@ -191,7 +197,7 @@ To add a state/source: 1. Create `providers/.py` with a class extending `ElectionProvider`. Implement `fetch(state)` → `list[Race]` and `provides_states()` (return the set of states it supplements, or `None` for a national baseline). -2. Normalize into the common `Race`/`Candidate` model (`data_model.py`). Use +2. Normalize into the common `Race`/`Candidate` model (`election_data_model.py`). Use `make_race_id(office, state, district)` so your races merge with the baseline. 3. Register it in `create_providers()` (`providers/__init__.py`), gated on its own `providers..enabled` config flag. diff --git a/plugins/ledmatrix-elections/config_schema.json b/plugins/ledmatrix-elections/config_schema.json index e1c7112f..cd9dcc38 100644 --- a/plugins/ledmatrix-elections/config_schema.json +++ b/plugins/ledmatrix-elections/config_schema.json @@ -12,7 +12,7 @@ "display_duration": { "type": "number", "title": "Ticker Duration (seconds)", - "description": "How long the scrolling ticker shows per rotation. Dynamic duration may extend this to fit all races.", + "description": "Fallback ticker duration. While races are loaded the ticker stays for exactly one pass over every race (at least 6 seconds), however long that takes.", "minimum": 10, "maximum": 300, "default": 30 diff --git a/plugins/ledmatrix-elections/manager.py b/plugins/ledmatrix-elections/manager.py index 9d6789e4..9833c236 100644 --- a/plugins/ledmatrix-elections/manager.py +++ b/plugins/ledmatrix-elections/manager.py @@ -316,14 +316,19 @@ def _config_events(self) -> List[ElectionEvent]: out: List[ElectionEvent] = [] for e in self.calendar_events_cfg: try: - out.append(ElectionEvent( + ev = ElectionEvent( state=(e.get("state") or self.state or "").upper(), date=e["date"], type=e.get("type", "general"), trail_days=int(e.get("trail_days", 14)), feed_filename=(e.get("feed_filename") or None), feed_url=(e.get("feed_url") or None), - )) + ) + # Parse the date here: a malformed one (e.g. "11/03/2026") + # otherwise raised later in resolve_active, failing every + # update so no election -- built-in or configured -- showed. + ev.election_day() + out.append(ev) except Exception: self.logger.warning("Elections: ignoring bad calendar_events entry: %s", e) return out diff --git a/plugins/ledmatrix-elections/manifest.json b/plugins/ledmatrix-elections/manifest.json index 144f7a18..0d042a7f 100644 --- a/plugins/ledmatrix-elections/manifest.json +++ b/plugins/ledmatrix-elections/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-elections", "name": "Election Results", - "version": "1.3.1", + "version": "1.3.2", "author": "rpierce99", "description": "Live election results: a scrolling ticker of important races plus a full-screen interrupt when a race is newly called. Auto-activates for your state's primary + general (dormant otherwise), drains stale calls from the ticker, and survives restarts. Filterable to your state. NYT baseline provider + optional California SoS county/city rollup.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ "cache_manager" ], "versions": [ + { + "version": "1.3.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "The called-race snapshot now survives a restart of any length, as the README says. It was read back with the cache's default five-minute max_age, so after a longer outage it was gone: calls made while down never interrupted, and races called after election day were re-dated to election day and dropped from the ticker at once. A calendar_events entry whose date is not YYYY-MM-DD is now skipped with a warning; it used to make every update raise, so no election showed at all. Docs: display_duration is described as the fallback it is (the ticker runs one full pass of every race), interrupt.enabled, the district aliases, calendar_events feed_url and providers.ca_sos.advance_count are documented, an empty providers.ca_sos.offices fetches nothing (not a built-in set), general NYT feeds are per-state, the California SoS source contributes nothing until offices or include_house is set, and the data-model file is election_data_model.py. The render-harness config now freezes the clock on election night: the fixture calls are dated to election day, so since June the 24-hour hide had dropped them all and the election_called check was drawing the ticker instead. Requires LEDMatrix core 3.4.0, as before." + }, { "version": "1.3.1", "released": "2026-09-16", @@ -97,7 +103,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-09-16", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": false, diff --git a/plugins/ledmatrix-elections/store.py b/plugins/ledmatrix-elections/store.py index abb430b3..5fea450b 100644 --- a/plugins/ledmatrix-elections/store.py +++ b/plugins/ledmatrix-elections/store.py @@ -48,7 +48,12 @@ def set_election(self, key: Optional[str]) -> None: self._called_at = {} if self.cache_manager and key: try: - cached = self.cache_manager.get(f"elections_snapshot_{key}") + # max_age=None: the core's get() otherwise expires an entry + # after 300s, so a restart more than five minutes after the + # last write lost the snapshot -- calls made while down never + # interrupted, and later calls were re-dated to election day + # and hidden from the ticker at once. + cached = self.cache_manager.get(f"elections_snapshot_{key}", max_age=None) except Exception: cached = None if cached: diff --git a/plugins/ledmatrix-elections/test/harness.json b/plugins/ledmatrix-elections/test/harness.json index 1e73705e..08157a99 100644 --- a/plugins/ledmatrix-elections/test/harness.json +++ b/plugins/ledmatrix-elections/test/harness.json @@ -1,5 +1,6 @@ { - "_comment": "Deterministic config for the plugin safety harness. test_mode parses the bundled June-2-2026 primary fixtures offline (no network), so both the ticker and called-card screens render identically on any host for golden-image comparison.", + "_comment": "Deterministic config for the plugin safety harness. test_mode parses the bundled June-2-2026 primary fixtures offline (no network), so both the ticker and called-card screens render identically on any host for golden-image comparison. freeze_time puts the clock on election night: the fixture calls are dated to election day, and hide_called_after_seconds (24h) would otherwise drop every one, leaving election_called to fall back to the ticker.", + "freeze_time": "2026-06-02 20:00:00", "config": { "enabled": true, "test_mode": true, diff --git a/plugins/ledmatrix-elections/test_calendar.py b/plugins/ledmatrix-elections/test_calendar.py index 43692dc1..16308c3a 100644 --- a/plugins/ledmatrix-elections/test_calendar.py +++ b/plugins/ledmatrix-elections/test_calendar.py @@ -172,10 +172,16 @@ def test_cold_start_seed(): # --------------------------------------------------------------------------- class FakeCache: + """Models the core's expiry: get() defaults to max_age=300 and an entry + older than that reads as a miss. ``age`` is how old every entry is.""" + def __init__(self): self.d = {} + self.age = 0.0 - def get(self, k): + def get(self, k, max_age=300): + if max_age is not None and self.age > max_age: + return None return self.d.get(k) def set(self, k, v): @@ -207,6 +213,15 @@ def test_persistence(): check(len(fired) == 1 and fired[0].id == "u-s-house-ca-12", "a fresh call after restart is still detected") + # A restart after a long outage (the snapshot is hours old) still loads + # it: the core's get() expires entries after 300s unless told otherwise. + cache.age = 6 * 3600 + s3 = RaceStore(cache_manager=cache) + s3.set_election(key) + check(s3._called_snapshot is not None and s3._called_at.get("u-s-house-ca-12") == 2100.0, + "a snapshot older than the cache's default max_age survives a restart") + cache.age = 0.0 + # Switching elections resets the snapshot. s2.set_election("CA_2026-11-03_general") check(s2._called_snapshot is None, "switching election resets the snapshot") @@ -293,6 +308,17 @@ def test_manager_resolution(): check(p._resolve_active(_ts("2026-03-25")) is None, "dormant again once the extra event's window passes") + # A malformed date in one entry is skipped, not fatal to the rest. + p = _plugin({"state": "TX", "calendar_events": [ + {"state": "TX", "date": "03/03/2026", "type": "primary"}, + {"state": "TX", "date": "2026-03-03", "type": "primary"}]}) + try: + ev = p._resolve_active(_ts("2026-03-05")) + ok = ev is not None and ev.date == "2026-03-03" + except Exception: + ok = False + check(ok, "a calendar_events entry with a bad date is ignored, the good one still resolves") + def test_local_provider_autoengage(): print("[Local provider auto-engage]") diff --git a/plugins/ledmatrix-leaderboard/README.md b/plugins/ledmatrix-leaderboard/README.md index b8578dc0..18442462 100644 --- a/plugins/ledmatrix-leaderboard/README.md +++ b/plugins/ledmatrix-leaderboard/README.md @@ -161,7 +161,7 @@ The full key list is in the [`enabled_sports`](#enabled_sports) table below. | `global.display.scroll_speed` | `1.0` | Pixels moved per scroll step (0.5–5.0). Speed is `scroll_speed / scroll_delay` px/s — see [Scroll speed](#scroll-speed). | | `global.display.scroll_delay` | `0.01` | Seconds per scroll step (0.001–0.1). | | `global.scroll_mode` | `"one_shot"` | Scrolling mode — one of `one_shot`, `continuous`. | -| `global.loop` | `false` | Continuously loop the leaderboard. | +| `global.loop` | `false` | Continuously loop the leaderboard. On, it overrides `scroll_mode` `one_shot`; `scroll_mode` `continuous` loops whatever this is set to. | | `global.appearance.pixel_perfect_text` | `true` | Render text with hard pixel edges. Disable only if you prefer the older anti-aliased (softer, blurrier) look. | | `global.appearance.crisp_logos` | `true` | Give logos hard edges instead of a ring of half-lit pixels. | | `global.appearance.text_outline` | `true` | Draw a black outline around text so it stays readable over logos. | diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 3a506635..7179ada9 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.5.4", + "version": "1.5.5", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "3.4.0", "versions": [ + { + "version": "1.5.5", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "README only: global.loop on overrides scroll_mode one_shot, as the settings form says." + }, { "version": "1.5.4", "released": "2026-09-28", @@ -151,7 +157,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "compatible_versions": [ ">=3.4.0" ] diff --git a/plugins/ledmatrix-music/CHANGELOG.md b/plugins/ledmatrix-music/CHANGELOG.md index 93be0b57..8a3344b6 100644 --- a/plugins/ledmatrix-music/CHANGELOG.md +++ b/plugins/ledmatrix-music/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.5.4] - 2026-10-02 + +### Fixed +- Spotify: the decoded album cover is kept between polls. The previous art URL + was read from the freshly replaced track dict, where it never exists, so + every progress-only poll (every 2 s) dropped the cover and display() + decoded it again. +- The `enabled` fallback is true, matching the schema and the core. + ## [1.5.3] - 2026-10-02 ### Fixed diff --git a/plugins/ledmatrix-music/manager.py b/plugins/ledmatrix-music/manager.py index d4a66a4e..ec22a508 100644 --- a/plugins/ledmatrix-music/manager.py +++ b/plugins/ledmatrix-music/manager.py @@ -211,7 +211,8 @@ def _load_config(self): try: # Flattened config access - no nested 'music' key - self.enabled = self.config.get("enabled", False) + # Same default as the schema and BasePlugin. + self.enabled = self.config.get("enabled", True) if not self.enabled: self.logger.info("Music plugin is disabled in config.") return @@ -833,7 +834,13 @@ def _poll_music_data(self): self.logger.debug("Polling Spotify: Significant change (title/artist/art/is_playing) detected.") else: self.logger.debug("Polling Spotify: Only progress changed, not significant.") - + + # Read before the swap below. It used to be read + # from the new dict, which never has it, so every + # progress-only poll discarded the decoded cover + # and display() decoded it again. + old_album_art_url = (self.current_track_info.get('album_art_url') + if self.current_track_info else None) self.current_track_info = simplified_info_poll self.current_source = MusicSource.SPOTIFY significant_change_for_callback = significant_change_detected @@ -847,7 +854,6 @@ def _poll_music_data(self): self.logger.debug("Polling Spotify: Minor update (progress only), no full refresh needed.") # Handle album art for Spotify - old_album_art_url = self.current_track_info.get('album_art_url_prev_spotify') new_album_art_url = simplified_info_poll.get('album_art_url') if new_album_art_url != old_album_art_url: self.album_art_image = None @@ -855,7 +861,6 @@ def _poll_music_data(self): # Downloaded at the end of this poll cycle by # _prefetch_current_album_art(), outside # track_info_lock so display() never waits on it. - self.current_track_info['album_art_url_prev_spotify'] = new_album_art_url self.logger.debug(f"Polling Spotify: Active track - {spotify_track.get('item', {}).get('name')}") else: diff --git a/plugins/ledmatrix-music/manifest.json b/plugins/ledmatrix-music/manifest.json index 6c2053da..e838c0ad 100644 --- a/plugins/ledmatrix-music/manifest.json +++ b/plugins/ledmatrix-music/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-music", "name": "Music Player - Now Playing", - "version": "1.5.3", + "version": "1.5.4", "description": "Real-time now playing display for Spotify and YouTube Music with album art, scrolling text, and progress bars", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -65,6 +65,12 @@ } ], "versions": [ + { + "version": "1.5.4", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "Spotify: the decoded album cover is kept between polls. The previous art URL was read from the freshly replaced track dict, where it never exists, so every progress-only poll (every 2 s) dropped the cover and display() decoded it again. The enabled fallback is now true, matching the schema and the core." + }, { "version": "1.5.3", "released": "2026-10-02", @@ -205,7 +211,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ledmatrix-stocks/README.md b/plugins/ledmatrix-stocks/README.md index 5b078b8b..971cf808 100644 --- a/plugins/ledmatrix-stocks/README.md +++ b/plugins/ledmatrix-stocks/README.md @@ -170,8 +170,9 @@ value it rejected. ### The inline chart -`display.toggle_chart` draws the day's price history behind the text, sized by -`chart_width_px` and `chart_height_px`: +`display.toggle_chart` draws the day's price history just right of each +symbol's text, sized by `chart_width_px` and `chart_height_px`. The chart is +part of the `scroll` ribbon; `switch` mode shows no chart: ![toggle_chart on and off](../../docs/assets/ledmatrix-stocks/toggle-chart.png) @@ -210,6 +211,7 @@ plugin: prices on one rotation slot, related headlines on another. **Chart isn't drawing** - Set `display.toggle_chart` to `true`. +- The chart is drawn only in `scroll` mode, not `switch`. - Charts need enough horizontal room next to each symbol. On a 64×32 panel they may be cropped — try a wider chain. diff --git a/plugins/ledmatrix-stocks/display_renderer.py b/plugins/ledmatrix-stocks/display_renderer.py index 6a16cb44..eff4828e 100644 --- a/plugins/ledmatrix-stocks/display_renderer.py +++ b/plugins/ledmatrix-stocks/display_renderer.py @@ -368,13 +368,9 @@ def create_static_display(self, symbol: str, data: Dict[str, Any]) -> Image.Imag is_crypto = data.get('is_crypto', False) - # Draw logo + # The logo is drawn once the text is measured (below), so the text + # can be placed beside it rather than on top of it. logo = self._get_stock_logo(symbol, is_crypto) - if logo: - # Ensure positions are integers - logo_x = 5 - logo_y = int((int(self.display_height) - logo.height) // 2) - image.paste(logo, (int(logo_x), int(logo_y)), logo) # Use custom fonts loaded from config symbol_font = self.symbol_font @@ -427,8 +423,26 @@ def create_static_display(self, symbol: str, data: Dict[str, Any]) -> Image.Imag else: change_bbox = (0, 0, 0, 0) - # Center everything - ensure integer - center_x = int(self.display_width) // 2 + # Center the text in the space right of the logo. Centering it on the + # whole panel drew it over the logo, which covers most of a 64-wide + # panel. The logo shrinks to the room the widest line leaves, and is + # left out (text centered on the panel) when that is under 8px. + logo_x = 5 + logo_gap = 4 + panel_width = int(self.display_width) + text_width = max(int(symbol_bbox[2] - symbol_bbox[0]), + int(price_bbox[2] - price_bbox[0]), + int(change_bbox[2] - change_bbox[0])) + center_x = panel_width // 2 + logo_room = panel_width - logo_x - logo_gap - text_width + if logo and logo_room >= 8: + if logo.width > logo_room: + logo = logo.copy() + logo.thumbnail((logo_room, logo_room)) + logo_y = int((int(self.display_height) - logo.height) // 2) + image.paste(logo, (int(logo_x), int(logo_y)), logo) + text_left = logo_x + logo.width + logo_gap + center_x = text_left + (panel_width - text_left) // 2 # Draw symbol symbol_width = int(symbol_bbox[2] - symbol_bbox[0]) diff --git a/plugins/ledmatrix-stocks/manifest.json b/plugins/ledmatrix-stocks/manifest.json index c75eadae..1a2d59e2 100644 --- a/plugins/ledmatrix-stocks/manifest.json +++ b/plugins/ledmatrix-stocks/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-stocks", "name": "Stock & Crypto Ticker", - "version": "2.11.2", + "version": "2.11.3", "description": "Displays stock tickers with prices, changes, and optional charts for stocks and cryptocurrencies. Supports scroll and switch display modes.", "author": "LEDMatrix Team", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/ledmatrix-stocks", @@ -38,6 +38,12 @@ } ], "versions": [ + { + "version": "2.11.3", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "Switch mode no longer draws the price over the logo. The text was centered on the whole panel, so on a 64-wide panel it covered the logo and on 128 wide the change line ran into it. The text is now centered beside the logo, which shrinks to fit the widest line and is left out when there is no room for it. README: the inline chart is drawn in scroll mode only." + }, { "version": "2.11.2", "released": "2026-09-28", @@ -151,7 +157,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ledmatrix-weather/CHANGELOG.md b/plugins/ledmatrix-weather/CHANGELOG.md index e051fbc5..0aebc04b 100644 --- a/plugins/ledmatrix-weather/CHANGELOG.md +++ b/plugins/ledmatrix-weather/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2.8.1] - 2026-10-02 + +### Documentation +- README lists `radar_in_vegas` and the `customization` section (fonts, + colours, visibility and offsets for the current-conditions screen). + ## [2.8.0] - 2026-09-30 ### Added diff --git a/plugins/ledmatrix-weather/README.md b/plugins/ledmatrix-weather/README.md index ae6a1339..62f46030 100644 --- a/plugins/ledmatrix-weather/README.md +++ b/plugins/ledmatrix-weather/README.md @@ -91,8 +91,10 @@ generated from it. The keys you'll touch most often: | `radar_update_interval` | `180` | Seconds between new-frame checks (60–1800). Checks are cheap; tiles only download when RainViewer publishes a new frame | | `radar_past_frames` | `6` | Observed frames to animate (~10 min apart) | | `radar_frame_seconds` / `radar_loop_pause_seconds` | `0.5` / `1.5` | Animation pacing: per-frame time and the hold on the newest frame | +| `radar_in_vegas` | `true` | In the Vegas ticker, show the animated radar after the forecast cards (needs LEDMatrix core 3.8.0 and `show_radar` on). Off leaves the radar out of the ticker; the full-screen radar mode is unaffected | | `dynamic_duration.enabled` | `false` | Opt-in: hold the radar until a full animation loop completes rather than cutting mid-loop | | `dynamic_duration.max_duration_seconds` | `60` | Ceiling on that hold, in seconds (10–300), so a slow loop cannot monopolise the panel | +| `customization` | *(shipped styling)* | Font, size, colour, visibility and position offsets for the current-conditions screen's elements: `condition_text`, `temp_text`, `high_low_text`, `metric_text` (bottom bar) and `weather_icon`. Edited from the **Display Customization** section of the web UI. Needs a core with the element-style system; older cores ignore it | ## Display modes diff --git a/plugins/ledmatrix-weather/manifest.json b/plugins/ledmatrix-weather/manifest.json index b32621f6..3dd4250d 100644 --- a/plugins/ledmatrix-weather/manifest.json +++ b/plugins/ledmatrix-weather/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-weather", "name": "Weather Display", - "version": "2.8.0", + "version": "2.8.1", "author": "ChuckBuilds", "class_name": "WeatherPlugin", "update_interval": 60, @@ -26,6 +26,12 @@ "radar" ], "versions": [ + { + "version": "2.8.1", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "Docs: the README now lists radar_in_vegas and the customization section (fonts, colours and offsets for the current-conditions screen). No code change." + }, { "version": "2.8.0", "released": "2026-09-30", @@ -246,7 +252,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/march-madness/README.md b/plugins/march-madness/README.md index ea1ca0d2..24296b8a 100644 --- a/plugins/march-madness/README.md +++ b/plugins/march-madness/README.md @@ -41,7 +41,6 @@ list. - **Round Logos**: Optional round-logo separators between game groups. - **Upset Highlighting**: Highlights upset winners (a higher seed beating a lower seed) in gold. -- **Bracket Progress**: Optionally shows which teams are still alive in each region. - **Favorite Teams**: Highlight your teams anywhere they appear in the bracket. - **Live Scores**: Live game scores with an automatically shortened refresh interval while games are in progress. @@ -95,7 +94,7 @@ truth. The keys below are the ones you'll typically set. | `leagues.ncaaw` | `true` | Show NCAA Women's Tournament games. | | `favorite_teams` | `[]` | Team abbreviations to highlight (e.g. `DUKE`, `UNC`). Empty shows all teams equally. | -### Display Options +### Display Options (`display_options`) | Key | Default | Range | Notes | |-----|---------|-------|-------| @@ -106,7 +105,7 @@ truth. The keys below are the ones you'll typically set. | `scroll_delay` | `0.02` | 0.001–0.1 | Seconds per step; see `scroll_speed`. | | `loop` | `true` | — | Loop the scroll continuously. | | `dynamic_duration` | `true` | — | Adjust the on-screen duration automatically based on content width. | -| `min_duration` | `30` | 10–300 | Minimum display duration in seconds (used with `dynamic_duration`). | +| `min_duration` | `30` | 10–300 | Minimum display duration in seconds. With `dynamic_duration` off, every slot lasts exactly this long. | | `max_duration` | `300` | 30–600 | Maximum display duration in seconds (used with `dynamic_duration`). | Three of these change what you see on the panel directly: @@ -117,7 +116,7 @@ Three of these change what you see on the panel directly: ![highlight_upsets on and off](../../docs/assets/march-madness/highlight-upsets.png) -### Data Settings +### Data Settings (`data_settings`) | Key | Default | Range | Notes | |-----|---------|-------|-------| diff --git a/plugins/march-madness/manifest.json b/plugins/march-madness/manifest.json index 707d4ab8..f735a16f 100644 --- a/plugins/march-madness/manifest.json +++ b/plugins/march-madness/manifest.json @@ -1,7 +1,7 @@ { "id": "march-madness", "name": "March Madness", - "version": "2.1.3", + "version": "2.1.4", "description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ ">=3.4.0" ], "versions": [ + { + "version": "2.1.4", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "README only: removed a Bracket Progress feature the plugin does not have; min_duration is the whole slot when dynamic_duration is off; section headings name display_options and data_settings." + }, { "version": "2.1.3", "released": "2026-09-28", @@ -117,7 +123,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/masters-tournament/README.md b/plugins/masters-tournament/README.md index aa67a43c..0fa49b31 100644 --- a/plugins/masters-tournament/README.md +++ b/plugins/masters-tournament/README.md @@ -118,7 +118,7 @@ Enable/disable specific modes and configure their settings: |---|---|---| | `enabled` | `true` | Enable or disable the Masters Tournament plugin. | | `display_duration` | `20` | Duration in seconds to display each mode before rotating (5–300). | -| `update_interval` | `30` | How often to fetch new data in seconds (30s during tournament, 3600s off-season) (10–3600). | +| `update_interval` | `30` | How often, in seconds, the plugin checks for new data (10–3600). The ESPN responses it fetches are cached for 30s while a round is on, 5 min in the three days before the start, and 1 h otherwise, so most checks off-season are served from the cache. | | `player_card_duration` | `8` | Seconds each player card is shown before rotating to the next player in the player card display mode (1–300). | | `hole_display_duration` | `15` | Seconds between hole advances in course tour and hole-by-hole display modes (1–300). | | `page_display_duration` | `15` | Seconds between page advances in paginated modes (leaderboard, champions, tournament stats, schedule, course overview) (1–300). | @@ -359,7 +359,7 @@ The Masters is typically held: - **Tournament**: Thursday-Sunday (April 9-12) Plugin automatically detects tournament phase and adjusts: -- Update intervals (30s live, 5m practice, 1h off-season) +- Data cache lifetime (30s live, 5m in the three days before the start, 1h otherwise) - Cache duration - Mode prioritization @@ -376,7 +376,7 @@ cd ~/Github/ledmatrix-plugins/plugins/masters-tournament sudo systemctl restart ledmatrix # Monitor logs -tail -f /var/log/ledmatrix/ledmatrix.log +sudo journalctl -u ledmatrix -f ``` ### Adding New Display Modes @@ -391,9 +391,9 @@ tail -f /var/log/ledmatrix/ledmatrix.log ### ESPN Golf API Endpoints -- **Leaderboard**: `https://site.api.espn.com/apis/site/v2/sports/golf/pga/leaderboard` -- **Schedule**: `https://site.api.espn.com/apis/site/v2/sports/golf/pga/schedule` -- **News**: `https://site.api.espn.com/apis/site/v2/sports/golf/pga/news` +- **Leaderboard** (also the source of tee times and tournament dates): `https://site.api.espn.com/apis/site/v2/sports/golf/leaderboard` +- **Player bio**: `https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/{player_id}` +- **Player overview**: `https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/{player_id}/overview` No API key required. Rate limits apply (plugin respects with caching). diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 86e7d9fa..bad4afb9 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "3.2.4", + "version": "3.2.5", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "category": "sports", "author": "ChuckBuilds", @@ -44,6 +44,12 @@ "height": 64 }, "versions": [ + { + "version": "3.2.5", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "README only: update_interval described as the check cadence (the 30s/5min/1h tiers are the data cache lifetime), ESPN endpoints corrected to the ones the plugin calls, log command is journalctl." + }, { "version": "3.2.4", "released": "2026-09-28", @@ -227,7 +233,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "compatible_versions": [ ">=3.4.0" ] diff --git a/plugins/mqtt-notifications/README.md b/plugins/mqtt-notifications/README.md index 99981aac..385f1cd7 100644 --- a/plugins/mqtt-notifications/README.md +++ b/plugins/mqtt-notifications/README.md @@ -90,7 +90,7 @@ centred, which suits short alerts and costs no CPU between redraws. | Key | Default | What it does | |---|---|---| -| `text.font_path` | `assets/fonts/PressStart2P-Regular.ttf` | Font file, TTF or BDF. Relative to the project root, or absolute. | +| `text.font_path` | `assets/fonts/PressStart2P-Regular.ttf` | Font file, TTF or BDF. Relative to the project root, or absolute. A BDF is a bitmap face and draws at the one size it declares, so `text.font_size` does not apply to it. | | `text.font_size` | `8` | Size in pixels. | | `text.text_color` | `[255, 255, 255]` | RGB triple. | | `text.background_color` | `[0, 0, 0]` | RGB triple. | @@ -289,11 +289,11 @@ To send a base64 encoded image, use the data URI format: ## Image Support -- **Formats**: PNG, JPEG, GIF (animated GIFs supported) +- **Formats**: PNG, JPEG, GIF (an animated GIF shows its first frame only) - **Base64**: Use data URI format: `data:image/png;base64,` - **File Paths**: Absolute paths or paths relative to LEDMatrix project root - **Resizing**: Images are automatically resized to fit the LED matrix while maintaining aspect ratio -- **Transparency**: RGBA images are converted to RGB with black background +- **Transparency**: RGBA images are flattened onto `text.background_color` (black by default) ## Troubleshooting diff --git a/plugins/mqtt-notifications/manager.py b/plugins/mqtt-notifications/manager.py index 9bb27e1b..81ddacc0 100644 --- a/plugins/mqtt-notifications/manager.py +++ b/plugins/mqtt-notifications/manager.py @@ -104,7 +104,8 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.last_update_time = time.time() self.text_image_cache: Optional[Image.Image] = None self.image_cache: Optional[Image.Image] = None - + self.image_failed = False + # Load font self.font = self._load_font() @@ -164,15 +165,14 @@ def _load_font(self): self.logger.info("Loaded TTF font: %s", font_path) return font elif font_path.lower().endswith('.bdf'): - try: - import freetype - face = freetype.Face(font_path) - face.set_pixel_sizes(0, self.font_size) - self.logger.info("Loaded BDF font: %s", font_path) - return face - except ImportError: - self.logger.warning("freetype not available for BDF font, using default") - return ImageFont.load_default() + # PIL's FreeType loader opens a .bdf, but only at the one pixel + # size it declares. This used to return a raw freetype.Face, + # which PIL cannot draw with, so every message failed to render. + native = self._bdf_pixel_size(font_path) + size = native if native is not None else int(self.font_size) + font = ImageFont.truetype(font_path, size) + self.logger.info("Loaded BDF font: %s @ %s", font_path, size) + return font else: self.logger.warning("Unsupported font type: %s", font_path) return ImageFont.load_default() @@ -323,8 +323,9 @@ def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-arg # Clear caches when new message arrives self.text_image_cache = None self.image_cache = None + self.image_failed = False self.scroll_pos = 0.0 - + # Trigger on-demand display self._trigger_on_demand_display(message) @@ -660,13 +661,20 @@ def display(self, force_clear: bool = False) -> bool: if 'image' in content and content['image']: # Display image if self.image_cache is None: - img = self._load_image(content['image']) + # One attempt per message: a failed load used to be retried + # (and logged as an error) on every frame of the fast loop. + img = None + first_attempt = not getattr(self, 'image_failed', False) + if first_attempt: + img = self._load_image(content['image']) + self.image_failed = img is None if img: self.image_cache = self._resize_image(img) else: # Fallback to text if image loading fails if 'text' in content and content['text']: - self.logger.warning("Image loading failed, falling back to text") + if first_attempt: + self.logger.warning("Image loading failed, falling back to text") content = {'text': content['text']} else: return False diff --git a/plugins/mqtt-notifications/manifest.json b/plugins/mqtt-notifications/manifest.json index c58af295..4e923648 100644 --- a/plugins/mqtt-notifications/manifest.json +++ b/plugins/mqtt-notifications/manifest.json @@ -1,7 +1,7 @@ { "id": "mqtt-notifications", "name": "MQTT Notifications", - "version": "1.2.7", + "version": "1.2.8", "author": "ChuckBuilds", "description": "Display text or images from HomeAssistant via MQTT. Supports dynamic MQTT topics with wildcard support for flexible notification handling that interrupts the normal display rotation.", "category": "integration", @@ -18,6 +18,12 @@ "entry_point": "manager.py", "class_name": "MQTTNotificationsPlugin", "versions": [ + { + "released": "2026-10-02", + "version": "1.2.8", + "changelog": "A BDF font set in text.font_path now renders: it was loaded as a raw freetype face PIL cannot draw with, so every text message failed. It loads through PIL at the size the file declares, and the freetype-py dependency is dropped. An image that fails to load is tried once per message instead of on every frame of the fast loop, each attempt logging an error. Docs: an animated GIF shows its first frame, and transparency is flattened onto text.background_color.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-28", "version": "1.2.7", @@ -97,7 +103,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/mqtt-notifications/requirements.txt b/plugins/mqtt-notifications/requirements.txt index 9f4bbc9a..bb83cc7c 100644 --- a/plugins/mqtt-notifications/requirements.txt +++ b/plugins/mqtt-notifications/requirements.txt @@ -1,3 +1,2 @@ paho-mqtt>=1.6.0 Pillow>=12.2.0 -freetype-py>=2.4.0 diff --git a/plugins/news/manager.py b/plugins/news/manager.py index 8204852e..495ac761 100644 --- a/plugins/news/manager.py +++ b/plugins/news/manager.py @@ -1051,10 +1051,13 @@ def _fetch_feed_headlines(self, feed_name: str, feed_url: str) -> List[Dict]: link = item.find('link') if title is not None and title.text: + # An empty has text None; unescaping it + # raised, and the whole feed was dropped as failed. + desc_text = description.text if description is not None else None headline = { 'feed_name': feed_name, 'title': html.unescape(title.text).strip(), - 'description': html.unescape(description.text).strip() if description is not None else '', + 'description': html.unescape(desc_text).strip() if desc_text else '', 'published': pub_date.text if pub_date is not None else '', 'link': link.text if link is not None else '', 'timestamp': datetime.now().isoformat() diff --git a/plugins/news/manifest.json b/plugins/news/manifest.json index 60d41e25..e57cff20 100644 --- a/plugins/news/manifest.json +++ b/plugins/news/manifest.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News Ticker", - "version": "1.6.4", + "version": "1.6.5", "description": "Displays scrolling news headlines from RSS feeds including sports news from ESPN, NCAA updates, and custom RSS sources", "author": "ChuckBuilds", "category": "content", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/news", "versions": [ + { + "released": "2026-10-02", + "version": "1.6.5", + "changelog": "A feed whose items have an empty is no longer dropped. Unescaping the missing text raised, and the whole feed was treated as failed.", + "ledmatrix_min_version": "3.4.0" + }, { "released": "2026-09-28", "version": "1.6.4", @@ -154,7 +160,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/nfl-draft/README.md b/plugins/nfl-draft/README.md index 5793cdcc..b563e2c3 100644 --- a/plugins/nfl-draft/README.md +++ b/plugins/nfl-draft/README.md @@ -37,15 +37,15 @@ Plugin path: plugins/nfl-draft | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | boolean | `true` | Enable/disable the plugin | -| `display_duration` | number | `60` | Display duration in seconds | +| `display_duration` | number | `60` | Display duration in seconds (10–300); used when `dynamic_duration.enabled` is off | | `font` | string | `"PressStart2P-Regular.ttf"` | Font file from assets/fonts/ | | `player_name_font_size` | integer | `12` | Font size for player names | | `detail_font_size` | integer | `8` | Font size for pick number / position / college | | `player_name_color` | object | `{r:255,g:255,b:255}` | Player-name colour, as separate `player_name_color.r`, `player_name_color.g` and `player_name_color.b` values (0–255) | | `pick_number_color` | object | `{r:255,g:255,b:255}` | Detail-line colour, as separate `pick_number_color.r`, `pick_number_color.g` and `pick_number_color.b` values (0–255) | | `scroll_speed` | number | `30` | Scroll speed in pixels per second, snapped to the nearest speed the panel can move in whole pixels (30 runs at 33.3 on a 100 Hz panel). **Changed in 2.1.0:** this setting used to be ignored and every install scrolled at 100 px/s; set `100` for that speed | -| `live_refresh_interval` | integer | `600` | Refresh interval during live draft (seconds) | -| `projection_refresh_interval` | integer | `86400` | Refresh interval for projections (seconds) | +| `live_refresh_interval` | integer | `600` | Refresh interval during the live draft and the April 20–27 draft window (seconds, 60–1800) | +| `projection_refresh_interval` | integer | `86400` | Refresh interval for projections outside the draft window (seconds, 3600–172800) | | `draft_year` | integer | `0` | Draft year (0 = auto-detect current/upcoming) | | `show_position` | boolean | `true` | Show player position | | `show_college` | boolean | `true` | Show player college/school | @@ -68,7 +68,7 @@ Plugin path: plugins/nfl-draft | Key | Default | Notes | |---|---|---| -| `dynamic_duration.enabled` | `true` | . | +| `dynamic_duration.enabled` | `true` | Keep the scroll on screen until it has run through every pick (between `min_duration` and `max_duration`). Off, each slot lasts `display_duration`. | | `dynamic_duration.min_duration` | `30` | Minimum display duration in seconds. | | `dynamic_duration.max_duration` | `300` | Maximum display duration in seconds. | | `vegas_mode` | `"scroll"` | Override how this plugin appears in Vegas scroll mode. 'scroll' = individual picks scroll through the stream (default), 'fixed' = entire display scrolls by as one block, 'static' = scroll pauses while plugin displays for its duration — one of `scroll`, `fixed`, `static`. | diff --git a/plugins/nfl-draft/manifest.json b/plugins/nfl-draft/manifest.json index 95b18b37..b7715ab2 100644 --- a/plugins/nfl-draft/manifest.json +++ b/plugins/nfl-draft/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-draft", "name": "NFL Draft", - "version": "2.2.1", + "version": "2.2.2", "author": "ChuckBuilds", "description": "Displays projected NFL draft picks from ESPN with live draft tracking support during the annual NFL Draft event. Includes simulate_live mode to replay a completed draft using real ESPN core API data. Shows team logos, player names, positions, and pick numbers in a scrolling display.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ], "icon": "fas fa-football-ball", "versions": [ + { + "version": "2.2.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "README only: dynamic_duration.enabled is described (the row was empty), display_duration applies only with dynamic duration off, and the two refresh intervals give their ranges and when each applies." + }, { "version": "2.2.1", "released": "2026-09-28", @@ -172,5 +178,5 @@ "nfl_logos": "assets/sports/nfl_logos/" }, "license": "GPL-3.0", - "last_updated": "2026-09-28" + "last_updated": "2026-10-02" } diff --git a/plugins/nrl-scoreboard/CHANGELOG.md b/plugins/nrl-scoreboard/CHANGELOG.md index dd3b9672..bdab8c2f 100644 --- a/plugins/nrl-scoreboard/CHANGELOG.md +++ b/plugins/nrl-scoreboard/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.34.2] - 2026-10-02 + +### Documentation +- README: `scroll_settings.scroll_speed` is documented with its real default, + `50` px/s (it said `1.0`). +- README: the `game_limits` precedence note matches the code: a + `game_limits` value changed from its default wins, otherwise the root key + decides (it said `game_limits` wins whenever present). +- README: documents `odds_update_interval` (`3600` s), + `live_odds_update_interval` (`60` s) and the full-screen + `scroll_card.switch_show_date` / `switch_show_time` switches. No change in + behaviour. + ## [1.34.1] - 2026-10-01 ### Changed diff --git a/plugins/nrl-scoreboard/README.md b/plugins/nrl-scoreboard/README.md index c994afa3..bb9dc8bf 100644 --- a/plugins/nrl-scoreboard/README.md +++ b/plugins/nrl-scoreboard/README.md @@ -114,9 +114,10 @@ Which of three regimes you are in depends on `favorite_teams` and | `other_games_divisions` | `["fbs"]` | Which divisions non-favorite games may come from. Inert here — see below. **Hidden from the config form since 1.29.0 (still declared).** | All eight are declared **twice**: at the root of the config and inside -`game_limits`. Both render in the web UI and both are read. **`game_limits` wins -where the key is present**, and the root value is used otherwise. Set one place -or the other, not both. +`game_limits`. Both render in the web UI and both are read. The web UI saves a +value into both copies, so **a `game_limits` value you changed from its default +wins**, and the root value decides otherwise. Set one place or the other, not +both. Within the other-games pool the better matchup leads and each team appears once. The pool is each team's *next* game ordered by the best poll position of either @@ -302,6 +303,8 @@ full turn before the board moves on. | `recent_update_interval` | 60–86400 s | `3600` | **Advanced.** Refresh cadence for finished games. | | `upcoming_update_interval` | 60–86400 s | `3600` | **Advanced.** Refresh cadence for the schedule. | | `stale_game_timeout` | 60–3600 s | `300` | **Advanced.** Drop a live game the API has stopped updating. | +| `odds_update_interval` | 60–86400 s | `3600` | **Advanced.** How long fetched betting odds for a game that is not live are reused before asking again. Only matters with `show_odds` on. | +| `live_odds_update_interval` | 15–3600 s | `60` | **Advanced.** The same for a live game. | | `no_data_interval_seconds` | 5–86400 s | `300` | **Advanced.** Wait between live checks when there are no live games. Backs off further the longer nothing is found. | | `live_idle_max_interval_seconds` | 5–86400 s | `900` | **Advanced.** Ceiling for that back-off. | | `schedule_lookback_days` | 1–60 | `14` | **Advanced.** How far back to fetch for the Recent screen. | @@ -360,7 +363,7 @@ Nudge any element in pixels. All default to `0`, all live under | Key | Type | Default | |---|---|---| -| `scroll_settings.scroll_speed` | 0.01–200 px/s | `1.0` | +| `scroll_settings.scroll_speed` | 0.01–200 px/s | `50` | | `scroll_settings.scroll_delay` | 0.001–0.1 s | `0.01` (ignored; kept so saved configs still load -- scrolling is paced to the panel refresh). **Hidden from the config form since 1.29.0 (still declared).** | | `scroll_settings.gap_between_games` | 8–128 px | `24` | | `scroll_settings.show_league_separators` | boolean | `true` | @@ -391,7 +394,8 @@ full-screen scoreboard. | Date Format | `scroll_card.date_format` | `abbrev` | Scroll and Vegas cards: `Sep 19`, `9/19`, `19 Sep`, `19/9`, or `Fri Sep 19`. | | Full-Screen Date Format | `scroll_card.switch_date_format` | `numeric` | **Advanced.** The same for the full-screen scoreboard, plus `inherit`. It has its own default because the two displays disagree about what is normal: the cards have always written `Sep 19` and the full-screen scoreboard `9/19`, so a single shared default would restyle one of them. | | Time Format | `scroll_card.time_format` | `12h` | 12- or 24-hour clock. | -| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line. | +| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line from the scroll and Vegas cards. | +| Full-Screen Show Date / Show Time | `scroll_card.switch_show_date`, `scroll_card.switch_show_time` | `true` | The same for the full-screen upcoming scoreboard. | | Full-Screen Recent Date | `scroll_card.switch_recent_show_date` | `true` | Draw the date a finished game was played along the bottom of the full-screen recent scoreboard, written in the Full-Screen Date Format. | | Swap Date and Time | `scroll_card.swap_date_time` | `false` | Flip the two lines. Each display starts from its own order, so this flips rather than forces: scroll and Vegas cards put the time on top, the full-screen stack puts the date on top. | diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 80529f0b..494178ba 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.34.1", + "version": "1.34.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.34.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "README only: scroll_settings.scroll_speed is documented with its real default (50 px/s, not 1.0); the game_limits precedence note now matches the code (a game_limits value changed from its default wins, otherwise the root key); documents odds_update_interval / live_odds_update_interval and the full-screen scroll_card.switch_show_date / switch_show_time switches. No change in behaviour." + }, { "version": "1.34.1", "released": "2026-10-01", @@ -541,7 +547,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/odds-ticker/README.md b/plugins/odds-ticker/README.md index 9221119b..65340fe4 100644 --- a/plugins/odds-ticker/README.md +++ b/plugins/odds-ticker/README.md @@ -21,17 +21,17 @@ size from seeded games so it reproduces exactly. Team records come from a live per-team ESPN lookup that these offline renders skip, which is why they read `(N/A)`.* -A plugin for LEDMatrix that displays scrolling odds and betting lines for upcoming games across multiple sports leagues including NFL, NBA, MLB, NCAA Football, and NCAA Basketball. +A plugin for LEDMatrix that displays scrolling odds and betting lines for upcoming games across multiple sports leagues including NFL, NBA, MLB, NHL, MiLB, NCAA Football, NCAA Men's Basketball, and NCAA Baseball. ## Features -- **Multi-Sport Support**: NFL, NBA, MLB, NCAA Football, NCAA Basketball +- **Multi-Sport Support**: NFL, NBA, MLB, NHL, MiLB, NCAA Football, NCAA Men's Basketball, NCAA Baseball - **Scrolling Ticker Display**: Continuous scrolling of odds information - **Betting Lines**: Point spreads, money lines, and over/under totals - **Favorite Teams**: Prioritize odds for your favorite teams - **Broadcast Information**: Show channel logos and game times - **Configurable Display**: Adjustable scroll speed, duration, and filtering options -- **Background Data Fetching**: Efficient API calls without blocking display +- **Fetch Outside the Draw Loop**: Data is fetched and cached in `update()`, never while the ticker is drawing ## Configuration @@ -151,8 +151,11 @@ The plugin supports the following sports leagues: - **nfl**: NFL (National Football League) - **nba**: NBA (National Basketball Association) - **mlb**: MLB (Major League Baseball) +- **nhl**: NHL (National Hockey League) +- **milb**: Minor League Baseball - **ncaa_fb**: NCAA Football - **ncaam_basketball**: NCAA Men's Basketball +- **ncaa_baseball**: NCAA Baseball ## Team Abbreviations @@ -171,18 +174,19 @@ Common abbreviations: UGA, AUB, BAMA, CLEM, OSU, MICH, FSU, LSU, OU, TEX, etc. ### NCAA Basketball Teams Common abbreviations: DUKE, UNC, KANSAS, KENTUCKY, UCLA, ARIZONA, GONZAGA, BAYLOR, VILLANOVA, MICHIGAN, etc. -## Background Service +## Data Fetching -The plugin uses background data fetching for efficient API calls: +All network calls run in `update()`, never from the draw loop: -- Requests timeout after 30 seconds (configurable) -- Up to 3 retries for failed requests -- Priority level 2 (medium priority) -- Updates every hour by default (configurable) +- Requests time out after `data_settings.request_timeout` seconds (30 by default) +- Refreshes every `data_settings.update_interval` seconds (an hour by default), or every + `data_settings.live_game_update_interval` seconds while live games are on the ticker +- ESPN scoreboards are cached per league and day; odds per game ## Data Sources -Odds data is fetched from various sports data APIs and aggregated for display. The plugin integrates with the main LEDMatrix odds management system. +Games come from ESPN's public scoreboard API and betting lines from ESPN's public odds +API (through LEDMatrix's shared odds manager). No API key is required. ## Dependencies diff --git a/plugins/odds-ticker/manifest.json b/plugins/odds-ticker/manifest.json index d580d9bf..0c158ee1 100644 --- a/plugins/odds-ticker/manifest.json +++ b/plugins/odds-ticker/manifest.json @@ -1,7 +1,7 @@ { "id": "odds-ticker", "name": "Odds Ticker", - "version": "1.7.6", + "version": "1.7.7", "description": "Displays scrolling odds and betting lines for upcoming games across multiple sports leagues including NFL, NBA, MLB, NCAA Football, and more", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/odds-ticker", "versions": [ + { + "version": "1.7.7", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "README only: lists all eight supported leagues (NHL, MiLB and NCAA Baseball were missing), describes data fetching as it is (in update(), request_timeout and the two refresh intervals) instead of a background service with retries and a priority it does not use, and names ESPN as the data source." + }, { "version": "1.7.6", "released": "2026-10-01", @@ -215,7 +221,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/of-the-day/README.md b/plugins/of-the-day/README.md index e84ac891..1a25a409 100644 --- a/plugins/of-the-day/README.md +++ b/plugins/of-the-day/README.md @@ -109,17 +109,17 @@ one](../../docs/assets/of-the-day/categories.png) | `of_the_day/word_of_the_day.json` | An English word a day, with definition and example | | `of_the_day/slovenian_word_of_the_day.json` | A Slovenian word a day, with its English meaning | -`category_order` controls the order categories are shown in. A category listed -in `category_order` but missing from `categories` is skipped, and one present -in `categories` but absent from `category_order` is appended after the -listed ones. +`category_order` controls the order categories are shown in. A name listed in +`category_order` that is not a category (no data file in `of_the_day/` and no +`categories` entry) is skipped, and a category absent from `category_order` is +appended after the listed ones. --- ## The Data File Format A data file is a JSON object keyed by **day of the year as a string**, `"1"` -through `"365"`: +through `"365"` (plus an optional `"366"` for 31 December of a leap year): ```json { @@ -148,7 +148,8 @@ day — and means a file needs 365 entries for full coverage. Missing days are skipped rather than erroring. Because it is keyed by day rather than date, the same file works every year. -For a leap year, day 366 has no entry unless you add one. +In a leap year 31 December is day 366; a file with no `"366"` entry shows its +day 365 entry that day, and one that has a `"366"` entry shows that instead. > Earlier documentation described these files as keyed by `YYYY-MM-DD`. They are > not — the bundled files and the loader both use the day-of-year number. A file @@ -160,8 +161,9 @@ For a leap year, day 366 has no entry unless you add one. - **Keep the subtitle short.** It is the line most often shown, and on a 128×32 panel roughly 24 characters survive before truncation. The description can be longer since it only needs to fit when it takes its turn. -- **Use the same fields throughout.** An entry missing `subtitle` or - `description` simply shows less; it does not fall back to another field. +- **Use the same fields throughout.** An entry missing `subtitle` shows the + title alone on that view; one missing `description` shows "No content" when + the description takes its turn. - **Cover all 365 days** if you want the category to appear every day. Gaps are skipped, so a sparse file means the category quietly disappears on the missing days. @@ -179,7 +181,7 @@ For a leap year, day 366 has no entry unless you add one. | `enabled` | boolean | `false` | Whether the plugin runs at all | | `categories` | object | *(empty)* | Your categories — see [above](#adding-a-category) | | `category_order` | array | `["word_of_the_day", "slovenian_word_of_the_day"]` | Display order | -| `display_duration` | number | `40` | Seconds each category holds the panel | +| `display_duration` | number | `40` | Seconds the plugin holds the panel per turn | | `display_rotate_interval` | number | `20` | Seconds before moving to the next category | | `subtitle_rotate_interval` | number | `10` | Seconds between the subtitle and description views | | `update_interval` | integer | `3600` | Seconds between checks for a new day | @@ -191,8 +193,8 @@ For a leap year, day 366 has no entry unless you add one. Three intervals stack, from slowest to fastest: -- **`display_duration`** (default `40`) — how long the whole category holds the - panel before the display controller moves on. +- **`display_duration`** (default `40`) — how long the plugin holds the panel + before the display controller moves on to the next plugin. - **`display_rotate_interval`** (default `20`) — how often the plugin moves on to the next enabled category. At the defaults, a 40-second turn with two categories shows each for 20 seconds. @@ -235,7 +237,11 @@ sizes](../../docs/assets/of-the-day/customization.png) | Element | `font` | `font_size` | `text_color` | |---------|--------|-------------|--------------| | `title_text` | `PressStart2P-Regular.ttf` | `8` (4–16) | `[255, 255, 255]` | -| `body_text` | `4x6-font.ttf` | `6` (4–12) | `[200, 200, 200]` | +| `body_text` | `4x6-font.ttf` | `6`\* (4–12) | `[200, 200, 200]` | + +\* A `font_size` equal to the schema default counts as untouched, and an +untouched body renders 4x6-font at 7px, its clean pixel grid. Any other value +is used as written. ```json { @@ -253,7 +259,19 @@ sizes](../../docs/assets/of-the-day/customization.png) > `color` in your config is silently ignored, with no warning and no visible > change. It is an easy mistake to make from reading the schema. -Both elements also accept `x_offset` and `y_offset` for nudging position. +Both elements also take `x_offset` and `y_offset` for nudging position. These +live in a separate `layout` block, not beside `font_size`: + +```json +{ + "customization": { + "layout": { + "title_text": { "x_offset": 0, "y_offset": 2 }, + "body_text": { "x_offset": 0, "y_offset": -1 } + } + } +} +``` Customization needs a LEDMatrix core with the element-style system. On an older core the section is not offered and the classic styling above is used, which is @@ -342,9 +360,16 @@ web UI rather than run directly. ### Tests +Run from the repo root with a LEDMatrix core checkout on the import path +(`PYTHONPATH=/path/to/LEDMatrix`); without it the tests cannot import +`src.plugin_system`: + ```bash python plugins/of-the-day/test_text_fitting.py python plugins/of-the-day/test_element_styles.py +python plugins/of-the-day/test_category_resolution.py +python plugins/of-the-day/test_leap_day_and_rollover.py +python plugins/of-the-day/test_timezone_and_config_change.py ``` ### Regenerating the images in this README diff --git a/plugins/of-the-day/config_schema.json b/plugins/of-the-day/config_schema.json index 28a67643..1d349f0b 100644 --- a/plugins/of-the-day/config_schema.json +++ b/plugins/of-the-day/config_schema.json @@ -89,7 +89,7 @@ "type": "number", "default": 40, "minimum": 10, - "description": "How long to display each category in seconds" + "description": "Seconds the plugin holds the panel per turn; categories rotate within it every display_rotate_interval" }, "file_manager": { "type": "null", @@ -106,7 +106,7 @@ "create": "create-file", "toggle": "toggle-category" }, - "upload_hint": "JSON files with day numbers 1\u2013365 as keys", + "upload_hint": "JSON files with day numbers 1\u2013366 as keys", "directory_label": "of_the_day/", "create_fields": [ { diff --git a/plugins/of-the-day/manager.py b/plugins/of-the-day/manager.py index a8b9a1ae..24e8c598 100644 --- a/plugins/of-the-day/manager.py +++ b/plugins/of-the-day/manager.py @@ -379,7 +379,12 @@ def display(self, force_clear: bool = False) -> None: self.last_rotation_time = current_time category_changed = True self.display_needs_update = True - + + # The list can shrink under the index (a new day where a sparse + # file has no entry); wrap instead of raising IndexError, which + # showed "Error" until the next category rotation. + self.current_category_index %= len(enabled_categories) + # Get current category category_name = enabled_categories[self.current_category_index] category_config = self.categories.get(category_name, {}) diff --git a/plugins/of-the-day/manifest.json b/plugins/of-the-day/manifest.json index 5e2fc3c2..09cd11dc 100644 --- a/plugins/of-the-day/manifest.json +++ b/plugins/of-the-day/manifest.json @@ -1,7 +1,7 @@ { "id": "of-the-day", "name": "Of The Day Display", - "version": "1.4.8", + "version": "1.4.9", "author": "ChuckBuilds", "description": "Display daily featured content like Word of the Day, Bible verses, or custom daily items. Supports multiple categories with rotating display and configurable data sources.", "category": "information", @@ -21,6 +21,12 @@ "of_the_day" ], "versions": [ + { + "released": "2026-10-02", + "version": "1.4.9", + "changelog": "When a new day leaves fewer categories with an entry than before (a sparse data file with no entry for today), the display wraps its category index instead of raising IndexError and showing 'Error' until the next category rotation. The file manager's upload action now rejects filenames containing '..', '/' or a backslash, as get/save/delete already did, and upload/save accept a day-366 key, which the loader already uses on 31 December of a leap year. README: display_duration is the plugin's whole turn (not per category), day 366 falls back to day 365, a missing description shows 'No content', offsets live under customization.layout, an untouched body font_size renders at 7px, and the test list is complete.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-28", "version": "1.4.8", @@ -109,7 +115,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/of-the-day/scripts/save_file.py b/plugins/of-the-day/scripts/save_file.py index 7fc335f3..9dad4c7b 100755 --- a/plugins/of-the-day/scripts/save_file.py +++ b/plugins/of-the-day/scripts/save_file.py @@ -46,7 +46,7 @@ if not isinstance(content, dict): print(json.dumps({ 'status': 'error', - 'message': 'JSON must be an object with day numbers (1-365) as keys' + 'message': 'JSON must be an object with day numbers (1-366) as keys' })) sys.exit(1) @@ -54,16 +54,16 @@ for key in content.keys(): try: day_num = int(key) - if day_num < 1 or day_num > 365: + if day_num < 1 or day_num > 366: print(json.dumps({ 'status': 'error', - 'message': f'Day number {day_num} is out of range (must be 1-365)' + 'message': f'Day number {day_num} is out of range (must be 1-366)' })) sys.exit(1) except ValueError: print(json.dumps({ 'status': 'error', - 'message': f'Invalid key "{key}": must be a day number (1-365)' + 'message': f'Invalid key "{key}": must be a day number (1-366)' })) sys.exit(1) diff --git a/plugins/of-the-day/scripts/upload_file.py b/plugins/of-the-day/scripts/upload_file.py index 769df727..135d8fb1 100755 --- a/plugins/of-the-day/scripts/upload_file.py +++ b/plugins/of-the-day/scripts/upload_file.py @@ -26,6 +26,15 @@ })) sys.exit(1) + # Security: ensure filename doesn't contain path traversal (same check + # as get/save/delete) + if '..' in filename or '/' in filename or '\\' in filename: + print(json.dumps({ + 'status': 'error', + 'message': 'Invalid filename' + })) + sys.exit(1) + # Validate filename if not filename.endswith('.json'): print(json.dumps({ @@ -48,7 +57,7 @@ if not isinstance(data, dict): print(json.dumps({ 'status': 'error', - 'message': 'JSON must be an object with day numbers (1-365) as keys' + 'message': 'JSON must be an object with day numbers (1-366) as keys' })) sys.exit(1) @@ -56,16 +65,16 @@ for key in data.keys(): try: day_num = int(key) - if day_num < 1 or day_num > 365: + if day_num < 1 or day_num > 366: print(json.dumps({ 'status': 'error', - 'message': f'Day number {day_num} is out of range (must be 1-365)' + 'message': f'Day number {day_num} is out of range (must be 1-366)' })) sys.exit(1) except ValueError: print(json.dumps({ 'status': 'error', - 'message': f'Invalid key "{key}": must be a day number (1-365)' + 'message': f'Invalid key "{key}": must be a day number (1-366)' })) sys.exit(1) diff --git a/plugins/of-the-day/test_leap_day_and_rollover.py b/plugins/of-the-day/test_leap_day_and_rollover.py index eb091ce2..c1bd9a63 100644 --- a/plugins/of-the-day/test_leap_day_and_rollover.py +++ b/plugins/of-the-day/test_leap_day_and_rollover.py @@ -62,7 +62,30 @@ def plugin(today, data): check("the first update after midnight loads the new day", p.current_day == date(2026, 9, 28) and p.current_items, p.current_day) +# 3. A new day can leave fewer categories with an entry (a sparse file has +# none for today). The category index pointed past the shorter list, so +# display() raised IndexError and showed "Error" until the next rotation. +p = object.__new__(OfTheDayPlugin) +p.logger = logging.getLogger("test-of-the-day") +p.categories = {"a": {"enabled": True}, "b": {"enabled": True}} +p.category_order = ["a", "b"] +p.current_items = {"a": {"title": "only"}} # "b" has no entry today +p.current_category_index = 1 # was showing "b" yesterday +p.rotation_state = 0 +now = time.time() +p.last_category_rotation_time = p.last_rotation_time = now +p.display_rotate_interval = p.subtitle_rotate_interval = 3600 +p.display_needs_update = True +p.last_displayed_category = p.last_displayed_rotation_state = None +drawn = [] +p._display_title = lambda cfg, item: drawn.append(("title", item)) +p._display_content = lambda cfg, item: drawn.append(("content", item)) +p._display_error = lambda: drawn.append(("error", None)) +p.display() +check("a shrunken category list wraps the index instead of showing Error", + drawn == [("title", {"title": "only"})], drawn) + print() -failed = [c for c, ok in results if not ok] +failed =[c for c, ok in results if not ok] print(f"{len(results) - len(failed)}/{len(results)} passed") sys.exit(1 if failed else 0) diff --git a/plugins/olympics/README.md b/plugins/olympics/README.md index e75922f0..3bb7ce3c 100644 --- a/plugins/olympics/README.md +++ b/plugins/olympics/README.md @@ -36,7 +36,7 @@ Screenshot Preview: - During Olympics: Countdown to closing ceremony - **Olympics Logo**: Displays Olympics logo (image or programmatically drawn Olympic rings) - **Adaptive Text Display**: Automatically adjusts text size and layout for different display sizes -- **Multiple Olympics Support**: Includes dates for upcoming Olympics through 2032 +- **Multiple Olympics Support**: Includes dates for the Games through Los Angeles 2028 ## Installation @@ -71,9 +71,8 @@ under `olympics`. The schema sets `additionalProperties: false`, so a key that is not listed below fails schema validation: the core logs a warning and flags the plugin as degraded in the web UI, but still loads it with that key ignored. `notifications_enabled`, `favorite_countries` and `webhooks` are still declared -for that reason: they are kept for compatibility and ignored, and are still -shown in the settings form (titled "deprecated") until core honours -`x-display: hidden`. The full schema is +for that reason: they are kept for compatibility and ignored, and are hidden +from the settings form (`x-display: hidden`). The full schema is [`config_schema.json`](config_schema.json). ### Basics @@ -174,7 +173,6 @@ The plugin will look for an Olympics logo image in the following locations: - `olympics-icon.png` - `logo.png` - `assets/olympics-logo.png` -- `assets/logo.png` If no image is found, the plugin will automatically draw the Olympic rings programmatically as a fallback. @@ -183,11 +181,11 @@ If no image is found, the plugin will automatically draw the Olympic rings progr ## Dependencies - Python 3.7+ -- PIL/Pillow (for image handling) +- PIL/Pillow and pytz (provided by LEDMatrix) +- `requests` and `lxml` (listed in `requirements.txt`; the plugin store installs + them) for the medal, schedule and results pages fetched during the Games - LEDMatrix 2.0.0 or higher -No additional Python packages are required beyond what LEDMatrix provides. - ## Troubleshooting ### Logo Image Not Displaying @@ -199,8 +197,8 @@ If the logo image doesn't appear: ### Countdown Not Updating -- The countdown updates based on `update_interval` (default: 1 hour) -- The countdown changes once per day, so hourly updates are sufficient +- Data refreshes every `update_interval` seconds (default: 300, five minutes) +- The day count itself is recomputed each time the screen is drawn - Check the plugin logs for any errors ### Text Not Fitting @@ -222,8 +220,11 @@ olympics/ ├── config_schema.json # Configuration schema ├── README.md # This file ├── requirements.txt # Python dependencies -└── assets/ # Optional: Olympics logo image - └── olympics-logo.png +├── olympics-logo.png # Logo drawn beside the countdown +├── data/ # Games table, olympics.com scraping, caching +├── renderers/ # Countdown, medal, event and alert cards +├── scripts/ # Asset/schedule generators (not loaded at runtime) +└── assets/ # Flags, sport icons, Olympic rings ``` ### Testing @@ -243,5 +244,5 @@ ChuckBuilds ## Version -1.0.0 +See `version` and the `versions` history in [`manifest.json`](manifest.json). diff --git a/plugins/olympics/manifest.json b/plugins/olympics/manifest.json index a8339233..c3822416 100644 --- a/plugins/olympics/manifest.json +++ b/plugins/olympics/manifest.json @@ -1,7 +1,7 @@ { "id": "olympics", "name": "Olympics", - "version": "3.1.4", + "version": "3.1.5", "author": "ChuckBuilds", "description": "Enhanced Olympics plugin with live medal counts, upcoming events, results, and countdown. Supports Vegas scroll mode and regular display mode.", "category": "sports", @@ -37,6 +37,12 @@ "country_tracking": true }, "versions": [ + { + "version": "3.1.5", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "README only: update_interval default is 300 seconds (said one hour), requests and lxml are required (said none), the logo search list, Games table, project layout and version reference match the plugin, and the deprecated keys are hidden from the settings form." + }, { "version": "3.1.4", "released": "2026-10-02", diff --git a/plugins/on-air/README.md b/plugins/on-air/README.md index ccb64d68..2469bde8 100644 --- a/plugins/on-air/README.md +++ b/plugins/on-air/README.md @@ -67,9 +67,9 @@ directly. The full schema is [`config_schema.json`](config_schema.json). | **Text Color** | `text_color` | `[255, 255, 255]` | RGB color of the sign text. | | **Background Color** | `background_color` | `[200, 10, 10]` | RGB background color — broadcast red by default. | | **Font** | `font_path` | *(blank)* | Path to a TTF font relative to the LEDMatrix root (e.g. `assets/fonts/PressStart2P-Regular.ttf`). Blank auto-selects one from the LEDMatrix assets folder, sized to 80% of display height. | -| **Font Size (px)** | `font_size` | `0` | Font height in pixels when a custom Font is set. `0` auto-sizes to 80% of display height. | +| **Font Size (px)** | `font_size` | `0` | Font height in pixels when a custom Font is set (`0` uses 8 px). Without a Font, the auto-selected font is sized to 80% of display height and this setting is ignored. | | **MQTT Broker Host** | `mqtt_host` | `localhost` | IP or hostname of your MQTT broker. | -| **MQTT Port** | `mqtt_port` | `1883` | Broker port (use 8883 for TLS). | +| **MQTT Port** | `mqtt_port` | `1883` | Broker port. TLS is not supported, so use a plain (non-TLS) listener. | | **MQTT Username** | `mqtt_username` | *(blank)* | Leave blank if no auth required. | | **MQTT Password** | `mqtt_password` | *(blank)* | Leave blank if no auth required. Marked secret, so the web UI masks it. | | **Command Topic** | `command_topic` | `ledmatrix/on-air/set` | Topic the plugin **subscribes** to (publish `ON`/`OFF` or JSON). | @@ -489,7 +489,7 @@ mosquitto_pub -h -t ledmatrix/on-air/set -m OFF ### The HA switch shows "Unavailable" - Confirm you reloaded your config after adding the `mqtt: switch:` block. -- Check the state topic: the switch won't show a valid state until the plugin has published at least once. Toggle it once from the command line to prime the state. +- Check the state topic: the plugin publishes its current state (and availability) every time it connects to the broker, so the switch should show `OFF` shortly after LEDMatrix starts. If it does not, the plugin is not reaching the broker. ### HA switch state doesn't update after sending OFF @@ -506,5 +506,5 @@ mosquitto_pub -h -t ledmatrix/on-air/set -m OFF - Set `retain: false` in your HA switch config. - Clear any retained messages on the command topic: ```bash - mosquitto_pub -h -t ledmatrix/on-air/set -m "" -r -n + mosquitto_pub -h -t ledmatrix/on-air/set -r -n ``` diff --git a/plugins/on-air/config_schema.json b/plugins/on-air/config_schema.json index 5d4f04cc..0050f136 100644 --- a/plugins/on-air/config_schema.json +++ b/plugins/on-air/config_schema.json @@ -82,7 +82,7 @@ "minimum": 1, "maximum": 65535, "title": "Broker Port", - "description": "Default is 1883. Use 8883 for TLS." + "description": "Default is 1883. TLS is not supported, so point this at a plain (non-TLS) listener." }, "mqtt_username": { "type": "string", diff --git a/plugins/on-air/manager.py b/plugins/on-air/manager.py index 3666215d..be7ae1e7 100644 --- a/plugins/on-air/manager.py +++ b/plugins/on-air/manager.py @@ -36,7 +36,18 @@ from src.plugin_system.base_plugin import BasePlugin -_PLUGIN_VERSION = "1.2.0" +def _manifest_version() -> str: + """manifest.json's version, so HA's sw_version tracks the release (a + hard-coded constant here stayed at 1.2.0 through every later bump).""" + try: + with open(Path(__file__).resolve().parent / 'manifest.json', + encoding='utf-8') as f: + return str(json.load(f).get('version', 'unknown')) + except (OSError, ValueError): + return 'unknown' + + +_PLUGIN_VERSION = _manifest_version() def _rgb(value, default) -> Tuple[int, int, int]: @@ -555,6 +566,13 @@ def _on_mqtt_connect(self, client, userdata, flags, rc): # pylint: disable=unus client.subscribe(self.command_topic, qos=1) self._publish_availability(True) self._publish_discovery() + # Re-announce the current state. Both topics are retained, so + # after a restart (the sign always comes up dark) Home Assistant + # kept showing the last ON until the next toggle. + with self.state_lock: + on_air, label = self.on_air, self.label + self._publish_state(on_air) + self._publish_label(label if on_air else '') self.logger.info("MQTT connected — subscribed to %s", self.command_topic) else: self.mqtt_connecting = False diff --git a/plugins/on-air/manifest.json b/plugins/on-air/manifest.json index 163a839a..06e7bb3f 100644 --- a/plugins/on-air/manifest.json +++ b/plugins/on-air/manifest.json @@ -1,7 +1,7 @@ { "id": "on-air", "name": "On Air Light", - "version": "1.2.13", + "version": "1.2.14", "author": "ChuckBuilds", "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live \u2014 stays on until you turn it off.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.2.14", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "Home Assistant no longer shows the sign as on after LEDMatrix restarts: the state and label topics are retained, and the plugin now republishes its current state each time it connects instead of only on the next toggle. The device's sw_version comes from manifest.json (it was stuck at 1.2.0). Docs: TLS is not supported (the port hint said to use 8883), font_size 0 means 8 px with a custom font, and the retained-message clearing command no longer passes both -m and -n." + }, { "version": "1.2.13", "released": "2026-09-29", @@ -123,7 +129,7 @@ "license": "GPL-3.0", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/on-air", "config_schema": "config_schema.json", - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/pomodoro-timer/README.md b/plugins/pomodoro-timer/README.md index 5566e3f2..f8e61b0b 100644 --- a/plugins/pomodoro-timer/README.md +++ b/plugins/pomodoro-timer/README.md @@ -178,7 +178,7 @@ edit the file directly or read it back in a log line. The full schema is |---|---|---|---| | **Enable MQTT Control** | `mqtt_enabled` | `true` | Connect to a broker so the timer can be driven remotely. | | **Broker Address** | `mqtt_host` | `localhost` | IP or hostname of your MQTT broker. | -| **Broker Port** | `mqtt_port` | `1883` | Use 8883 for TLS. | +| **Broker Port** | `mqtt_port` | `1883` | The plugin connects without TLS, so point it at a plain-MQTT listener (usually 1883); a TLS-only port such as 8883 will not connect. | | **Username / Password** | `mqtt_username`
`mqtt_password` | *(blank)* | Leave blank for an anonymous broker. The password is marked secret, so the web UI masks it. | | **Command Topic** | `command_topic` | `ledmatrix/pomodoro/set` | Everything the plugin publishes is derived from this topic's base. | | **State Topic** | `state_topic` | `ledmatrix/pomodoro/state` | `ON` while a session is active, `OFF` when idle. | @@ -206,7 +206,7 @@ edit the file directly or read it back in a log line. The full schema is | **Pulse the Current Session Dot** | `pulse_active_pip` | `true` | Slowly blink the pip for the session you're in, so the row reads as "two done, on the third" rather than just a count. | | **Work / Short Break / Long Break / Idle / Paused Label** | `work_label`
`short_break_label`
`long_break_label`
`idle_label`
`paused_label` | `FOCUS` / `BREAK` / `LONG BREAK` / `POMODORO` / `PAUSED` | The on-screen text for each state, up to 24 characters. Blank the Paused Label to keep showing the phase name while paused. | | **Font** | `font_path` | *(blank)* | Path to a TTF relative to the LEDMatrix root, e.g. `assets/fonts/PressStart2P-Regular.ttf`. Blank uses the display's default font. | -| **Font Size (px)** | `font_size` | `0` | Fix the countdown height in pixels. `0` sizes it automatically to the panel. | +| **Font Size (px)** | `font_size` | `0` | Fix the font height in pixels for the phase label and, with the `pixel` digit style, the countdown. `0` sizes them automatically to the panel. The `seven_segment` countdown ignores it, and a label taller than its row is not drawn. | ### Behavior diff --git a/plugins/pomodoro-timer/config_schema.json b/plugins/pomodoro-timer/config_schema.json index 93209875..910bda77 100644 --- a/plugins/pomodoro-timer/config_schema.json +++ b/plugins/pomodoro-timer/config_schema.json @@ -126,7 +126,7 @@ "minimum": 1, "maximum": 65535, "title": "Broker Port", - "description": "Default is 1883. Use 8883 for TLS." + "description": "Default is 1883. The connection is plain MQTT: TLS is not supported, so a TLS-only port such as 8883 will not connect." }, "mqtt_username": { "type": "string", diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index b6465bf0..da3cc0c1 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -48,7 +48,19 @@ from src.plugin_system.base_plugin import BasePlugin -_PLUGIN_VERSION = "1.0.0" + +def _manifest_version() -> str: + """manifest.json's version, so HA's sw_version tracks the release (a + hard-coded constant here stayed at 1.0.0 through every later bump).""" + try: + with open(Path(__file__).resolve().parent / "manifest.json", + encoding="utf-8") as f: + return str(json.load(f).get("version", "unknown")) + except (OSError, ValueError): + return "unknown" + + +_PLUGIN_VERSION = _manifest_version() PHASE_IDLE = "idle" PHASE_WORK = "work" @@ -447,6 +459,11 @@ def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: duration = max(1.0, float(payload["duration_seconds"])) except (TypeError, ValueError): duration = None + # JSON accepts 1e999 (and float() accepts "inf"); an infinite + # deadline made every snapshot raise OverflowError, so the panel + # showed the error screen until a RESET. + if duration is not None and not math.isfinite(duration): + duration = None if command in ("START", "ON", "PLAY"): started = True @@ -568,7 +585,7 @@ def _set_number_locked(self, field: str, raw: Any) -> bool: _, _, low, high, _, _ = spec try: value = int(round(float(raw))) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): self.logger.warning("Invalid value for %s: %r", field, raw) return False value = max(low, min(high, value)) diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index 9b0ebb02..9eac2f44 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.3.9", + "version": "1.3.10", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT \u2014 with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.3.10", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "A JSON command with an infinite duration (1e999, or \"inf\" as a string) is ignored instead of setting an infinite deadline, which made every frame and every state publish raise OverflowError until a RESET. The Home Assistant device's sw_version is read from manifest.json; it was hard-coded at 1.0.0. Docs: the broker connection has no TLS, so the 'Use 8883 for TLS' hint is gone from the settings form and README, and the README's font_size row now says it sizes the label and pixel-style countdown, not the seven-segment one." + }, { "version": "1.3.9", "released": "2026-09-29", @@ -118,7 +124,7 @@ "license": "GPL-3.0", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/pomodoro-timer", "config_schema": "config_schema.json", - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/pomodoro-timer/test_pomodoro_timer.py b/plugins/pomodoro-timer/test_pomodoro_timer.py index 66e8436c..c01c4ff5 100644 --- a/plugins/pomodoro-timer/test_pomodoro_timer.py +++ b/plugins/pomodoro-timer/test_pomodoro_timer.py @@ -256,6 +256,24 @@ def test_json_command_with_custom_duration_and_label(): assert p.work_minutes == 25 +def test_an_infinite_duration_is_ignored_not_fatal(): + # 1e999 is valid JSON for infinity; it used to set an infinite deadline + # that made every snapshot (and so every frame) raise OverflowError. + for raw in (b'{"command": "start", "duration_minutes": 1e999}', + b'{"command": "start", "duration_seconds": "inf"}'): + p = make_plugin() + p._apply_command(*p._parse_command(raw)) + snap = p._snapshot() + assert snap["phase"] == "work" + assert snap["remaining"] == "25:00" + assert p.display() is True + + +def test_sw_version_tracks_the_manifest(): + manifest = json.loads((PLUGIN_DIR / "manifest.json").read_text(encoding="utf-8")) + assert MODULE._PLUGIN_VERSION == manifest["version"] + + def test_settings_only_payload_updates_durations(): p = make_plugin() command, payload = p._parse_command(b'{"work_minutes": 45, "short_break_minutes": 10}') diff --git a/plugins/soccer-scoreboard/CHANGELOG.md b/plugins/soccer-scoreboard/CHANGELOG.md index 6d2853d9..4645dc21 100644 --- a/plugins/soccer-scoreboard/CHANGELOG.md +++ b/plugins/soccer-scoreboard/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2.39.2] - 2026-10-02 + +### Fixed +- A custom league whose `game_limits` were never opened in the web UI + (the row editor sends them back blank) showed 5 recent games, not the `1` + its settings page displays: `_adapt_config_for_custom_league` now falls + back to the schema default. +- `custom_leagues[].filtering.show_all_live` defaults to `false` in the + schema, matching the built-in leagues and what the plugin already did when + the value was absent; the settings page showed it on while it was off. + +### Documentation +- README: documents `.odds_update_interval` (`3600` s), + `.live_odds_update_interval` (`60` s) and the full-screen + `scroll_card.switch_show_date` / `switch_show_time` switches. + ## [2.39.1] - 2026-10-01 ### Changed diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index d5dedfe9..a92e38bd 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -457,6 +457,8 @@ See [The selection settings](#the-selection-settings). | `.recent_update_interval` | number | `3600` | How often the finished-matches list is rebuilt. This also sets how soon a match that has just ended can appear. | | `.upcoming_update_interval` | number | `3600` | How often the upcoming-matches list is rebuilt. Selection and the non-favorite rotation both run on the display side, so this governs only the fetch. | | `.stale_game_timeout` | number | `300` | Drop a live match the API has stopped updating. | +| `.odds_update_interval` | 60–86400 s | `3600` | How often betting odds for recent and upcoming matches are refreshed. Odds are cached per match, so this bounds requests rather than adding them. Only matters with `show_odds` on. | +| `.live_odds_update_interval` | 15–3600 s | `60` | How often betting odds for matches in progress are refreshed. | ### Celebrations @@ -546,7 +548,8 @@ Plugin-wide, not per league. | Date Format | `scroll_card.date_format` | `abbrev` | Scroll and Vegas cards: `Sep 19`, `9/19`, `19 Sep`, `19/9`, or `Fri Sep 19`. | | Full-Screen Date Format | `scroll_card.switch_date_format` | `numeric` | **Advanced.** The same for the full-screen scoreboard, plus `inherit`. It has its own default because the two displays disagree about what is normal. | | Time Format | `scroll_card.time_format` | `12h` | 12- or 24-hour clock. | -| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line. | +| Show Date / Show Time | `scroll_card.show_date`, `scroll_card.show_time` | `true` | Drop either line from the scroll and Vegas cards. | +| Full-Screen Show Date / Show Time | `scroll_card.switch_show_date`, `scroll_card.switch_show_time` | `true` | The same for the full-screen upcoming scoreboard. | | Full-Screen Recent Date | `scroll_card.switch_recent_show_date` | `true` | Draw the date a finished game was played along the bottom of the full-screen recent scoreboard, written in the Full-Screen Date Format. | | Swap Date and Time | `scroll_card.swap_date_time` | `false` | Flip the two lines. Each display starts from its own order, so this flips rather than forces. | diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index dc9d8731..1c896461 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -5530,7 +5530,7 @@ }, "show_all_live": { "type": "boolean", - "default": true, + "default": false, "description": "Show all live games, not just favorites" }, "favorite_live_boost": { diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 7cfc5cd8..4711ac26 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -871,7 +871,7 @@ def _adapt_config_for_custom_league(self, custom_league: Dict[str, Any]) -> Dict "favorite_teams": custom_league.get("favorite_teams", []), "exclude_teams": custom_league.get("exclude_teams", []), "display_modes": manager_display_modes, - "recent_games_to_show": game_limits.get("recent_games_to_show", 5), + "recent_games_to_show": game_limits.get("recent_games_to_show", 1), # These ride the same source as the limits above, which is where the # schema declares them. Managers read a translated config, not the # plugin config, so a key missing here is a setting the user can @@ -882,7 +882,7 @@ def _adapt_config_for_custom_league(self, custom_league: Dict[str, Any]) -> Dict ), "other_recent_games_to_show": game_limits.get( "other_recent_games_to_show", - game_limits.get("recent_games_to_show", 5), + game_limits.get("recent_games_to_show", 1), ), "other_rotation_interval_seconds": game_limits.get( "other_rotation_interval_seconds", 1800 diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index f212c3a6..bab33e9c 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.39.1", + "version": "2.39.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -53,6 +53,12 @@ "soccer_usa.1_upcoming" ], "versions": [ + { + "version": "2.39.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "A custom league whose game limits were never opened in the web UI showed 5 recent games instead of the 1 its settings page displays; it now uses the schema default of 1. The custom-league filtering.show_all_live default is now false in the schema, matching the built-in leagues and what the plugin already did, so the settings page no longer shows the switch on when it is off. README documents the per-league odds_update_interval / live_odds_update_interval and the full-screen scroll_card.switch_show_date / switch_show_time switches." + }, { "version": "2.39.1", "released": "2026-10-01", @@ -693,7 +699,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/static-image/README.md b/plugins/static-image/README.md index 8227c8e4..57f15567 100644 --- a/plugins/static-image/README.md +++ b/plugins/static-image/README.md @@ -238,8 +238,10 @@ Images outside their window are skipped by the rotation entirely. The common mistake is setting `mode` and the times but leaving `enabled` at `false`, which leaves the image always visible. -If every image is scheduled out at once there is nothing eligible to draw, so -keep at least one image unscheduled as a fallback. +If every image is outside its window at once, the schedules are set aside and +the whole list rotates as if none were scheduled — a per-image schedule never +blanks the panel. Keep at least one image unscheduled (or always in its window) +if you want to control what shows in the gaps. Windows are read in the LEDMatrix timezone from the main settings, not the Pi's system zone, so `08:00` means 08:00 where the panel is. The system clock @@ -266,6 +268,10 @@ image to use a wide panel, give it a wide source. `enabled` defaults to `false`. After that, check the `images` array is not empty and that at least one entry's `path` resolves. +**The panel shows "Image Error" in red.** +The current entry's file could not be found or decoded. The log names the path +it tried; check `path` as below. + **The image is missing but others show.** Check the log for a load warning. `path` is relative to the LEDMatrix project root, not to the plugin directory. @@ -330,8 +336,8 @@ python scripts/render_docs_assets.py --plugin static-image `--check` verifies the committed images still match what the plugin renders. The sample marks live in `docs/assets/static-image/sample/`. -Note that rotation cannot be shown in a still: every `rotation_mode` starts on -the first eligible image, and the differences only appear across frames. The +Note that rotation cannot be shown in a still: a screenshot catches one image, +and the differences between modes only appear across frames. The rotation behaviour above is documented from the source rather than from a screenshot. diff --git a/plugins/static-image/manager.py b/plugins/static-image/manager.py index 742ed719..b3607ed3 100644 --- a/plugins/static-image/manager.py +++ b/plugins/static-image/manager.py @@ -857,8 +857,12 @@ def _calculate_fit_size(self, image_size: Tuple[int, int], scale_x = display_width / img_width scale_y = display_height / img_height scale = min(scale_x, scale_y) - - return (int(img_width * scale), int(img_height * scale)) + + # A very wide or very tall source rounds its short side down to zero + # (2000x3 into 64x32 gives 64x0). Image.resize rejects a zero side, so + # the load failed and the panel showed "Image Error". One pixel is the + # smallest thing that can still be seen. + return (max(1, int(img_width * scale)), max(1, int(img_height * scale))) def update(self) -> None: """ @@ -1031,9 +1035,16 @@ def _display_error(self) -> None: (0, 0, 0)) if error_font: + from PIL import ImageDraw self.display_manager.image = img.copy() - self.display_manager.draw_text("Image", x=5, y=12, font=error_font, centered=False) - self.display_manager.draw_text("Error", x=5, y=20, font=error_font, centered=False) + # draw_text draws through display_manager.draw, which still + # pointed at the previous canvas: the text went onto an image + # nobody showed and the panel was pushed blank. The font + # manager does not draw with its registered colour either, so + # the red is passed here. + self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.draw_text("Image", x=5, y=12, color=(255, 0, 0), font=error_font, centered=False) + self.display_manager.draw_text("Error", x=5, y=20, color=(255, 0, 0), font=error_font, centered=False) else: # Fallback to direct PIL if font manager fails from PIL import ImageDraw, ImageFont diff --git a/plugins/static-image/manifest.json b/plugins/static-image/manifest.json index 6a64607c..b1bf8833 100644 --- a/plugins/static-image/manifest.json +++ b/plugins/static-image/manifest.json @@ -1,7 +1,7 @@ { "id": "static-image", "name": "Static Image Display", - "version": "1.1.7", + "version": "1.1.8", "author": "ChuckBuilds", "description": "Display static images on your LED matrix with automatic scaling, aspect ratio preservation, and transparency support. Perfect for logos, artwork, or custom graphics.", "entry_point": "manager.py", @@ -18,6 +18,12 @@ "static_image" ], "versions": [ + { + "version": "1.1.8", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "The \"Image Error\" screen is visible. It swapped in a blank canvas and then drew its text through the display's previous drawing context, so the words landed on a frame nobody showed and the panel went blank whenever a font manager was present (always, on a board). It also drew in white; it is now red, as intended. A very wide or very tall image (say 2000x3) loads instead of erroring: fitting it rounded the short side down to zero, which Image.resize rejects. README: when every image is outside its schedule window the schedules are set aside and the whole list rotates (the old text said nothing would draw)." + }, { "version": "1.1.7", "released": "2026-09-28", @@ -112,7 +118,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/static-image/test_error_screen_and_fit.py b/plugins/static-image/test_error_screen_and_fit.py new file mode 100644 index 00000000..aadf9638 --- /dev/null +++ b/plugins/static-image/test_error_screen_and_fit.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Regression tests: the "Image Error" screen is visible, and an extreme +aspect ratio still loads. + +1. _display_error() assigned a new blank canvas to display_manager.image and + then drew through display_manager.draw, which still pointed at the previous + canvas. The text went onto an image nobody showed and the panel was pushed + blank -- whenever a font manager was present, which is always on a real + board. It also passed no colour, so the text would have been white rather + than the red the plugin registers for errors. +2. _calculate_fit_size() rounded a very wide or very tall source's short side + down to zero (2000x3 into 64x32 gives 64x0); Image.resize rejects that, the + load failed, and the image read as an error. + +Run: LEDMATRIX_CORE=/path/to/LEDMatrix python plugins/static-image/test_error_screen_and_fit.py +Exit 0 pass, 2 skip (no core checkout), 1 fail. +""" + +import logging +import os +import sys +import tempfile +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) +_core = os.environ.get("LEDMATRIX_CORE") +_candidates = [Path(_core)] if _core else [] +_candidates.append(PLUGIN_DIR.parents[2] / "LEDMatrix") +CORE = None +for candidate in _candidates: + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + CORE = candidate + sys.path.insert(0, str(candidate)) + break +if CORE is None: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +try: + from PIL import Image, ImageFont + from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager + import manager +except Exception as exc: + print("SKIP: missing dependency (%s)" % exc) + sys.exit(2) + +logging.disable(logging.CRITICAL) + +results = [] + + +def check(case, passed, detail=""): + results.append(passed) + print(f" [{'pass' if passed else 'FAIL'}] {case}" + ("" if passed or not detail else f" -- {detail}")) + + +class _FontManager: + """Stands in for the core FontManager: serves PressStart2P at any size.""" + + def register_manager_font(self, **kwargs): + pass + + def resolve_font(self, element_key=None, family=None, size_px=8): + return ImageFont.truetype(str(CORE / "assets" / "fonts" / "PressStart2P-Regular.ttf"), size_px) + + +class _PluginManager: + font_manager = _FontManager() + + +def make(images, w=64, h=32): + dm = VisualTestDisplayManager(w, h) + config = {"enabled": True, "images": images, "fit_to_display": True, + "preserve_aspect_ratio": True} + return dm, manager.StaticImagePlugin("static-image", config, dm, None, _PluginManager()) + + +print("the error screen") +dm, plugin = make([{"id": "gone", "path": "does/not/exist.png"}]) +# Leave a lit frame behind, as the previous plugin's screen would be. +dm.image = Image.new("RGB", (64, 32), (0, 0, 255)) +from PIL import ImageDraw # noqa: E402 +dm.draw = ImageDraw.Draw(dm.image) +plugin.display() +frame = dm.image.convert("RGB") +pixels = [frame.getpixel((x, y)) for y in range(frame.height) for x in range(frame.width)] +red = sum(1 for p in pixels if p[0] > 150 and p[1] < 80 and p[2] < 80) +blue = sum(1 for p in pixels if p[2] > 150 and p[0] < 80) +check("'Image Error' is drawn on the frame the panel shows", red > 20, f"{red} red pixels") +check("the previous screen is not left behind", blue == 0, f"{blue} blue pixels") + +print("an extreme aspect ratio") +tmp = Path(tempfile.mkdtemp(prefix="static-image-fit-")) +banner = tmp / "banner.png" +Image.new("RGB", (2000, 3), (0, 255, 0)).save(banner) +dm, plugin = make([{"id": "banner", "path": str(banner)}]) +check("a 2000x3 banner loads into 64x32", plugin.image_loaded) +check("the fit size never has a zero side", + min(plugin._calculate_fit_size((2000, 3), (64, 32))) >= 1 + and min(plugin._calculate_fit_size((3, 2000), (64, 32))) >= 1) + +print(f"\n{sum(results)}/{len(results)} passed") +sys.exit(0 if all(results) else 1) diff --git a/plugins/stock-news/README.md b/plugins/stock-news/README.md index 20659d00..9d5ac015 100644 --- a/plugins/stock-news/README.md +++ b/plugins/stock-news/README.md @@ -95,7 +95,7 @@ The full schema is [`config_schema.json`](config_schema.json). | Key | Default | Notes | |---|---|---| | `feeds.stock_symbols` | `["AAPL", "GOOGL", "MSFT"]` | Stock symbols — headlines fetched from Yahoo Finance. Fetches are spread across update_interval_seconds. | -| `feeds.custom_feeds` | *(empty)* | Extra RSS feeds to include alongside stock symbols. | +| `feeds.custom_feeds` | *(empty)* | Extra RSS feeds to include alongside stock symbols. Each entry is `{"name": ..., "url": ...}`; `name` is drawn as the story's label where a symbol would be. | | `feeds.text_color` | `"#00ff00"` | Color for headline text. | | `feeds.symbol_color` | `"#ffff00"` | Color for stock symbol labels (e.g. 'AAPL:'). | | `feeds.publisher_color` | `"#6e6e6e"` | Color for the publisher segment (e.g. '• Reuters'). Advanced. | diff --git a/plugins/stock-news/manifest.json b/plugins/stock-news/manifest.json index bcc98bb7..0340ed33 100644 --- a/plugins/stock-news/manifest.json +++ b/plugins/stock-news/manifest.json @@ -1,7 +1,7 @@ { "id": "stock-news", "name": "Stock News Ticker", - "version": "2.8.3", + "version": "2.8.4", "author": "ChuckBuilds", "description": "Live stock headlines via Yahoo Finance search API with RSS fallback, company logos, configurable display styles (logo+ticker, ticker only, logo only), Vegas scroll integration, and per-day/hour request budgeting", "entry_point": "manager.py", @@ -25,6 +25,12 @@ "branch": "main", "plugin_path": "plugins/stock-news", "versions": [ + { + "version": "2.8.4", + "released": "2026-10-02", + "notes": "Docs: the README describes a custom feed's name and url fields. No code change.", + "ledmatrix_min_version": "3.4.0" + }, { "version": "2.8.3", "released": "2026-10-01", @@ -196,7 +202,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/text-display/README.md b/plugins/text-display/README.md index 43025731..648151f3 100644 --- a/plugins/text-display/README.md +++ b/plugins/text-display/README.md @@ -183,9 +183,9 @@ coming round again. A gap roughly equal to your panel width gives the cleanest loop — the message is fully gone before it returns. The default of 32 suits a 64-wide panel; on a 128-wide chain, try 128. -With `scroll_loop: false` the text scrolls past once and stops. Pair it with -`display_duration` long enough for a full pass, or the plugin's turn will end -mid-message. +With `scroll_loop: false` the text scrolls past once and stops. The turn is +stretched to cover that pass (see [Timing](#timing)), and any time left over +holds the parked end frame. > **A still cannot show motion.** There is no screenshot of scrolling in this > README because a single frame captured at the start of a scroll is an empty @@ -213,19 +213,24 @@ deliberate alert, not as a default. | Option | Type | Default | What it does | |--------|------|---------|--------------| -| `display_duration` | number | `10` | Seconds the plugin holds the panel per turn | -| `update_interval` | integer | `60` | Seconds between refreshes of the text | +| `display_duration` | number | `10` | Seconds the plugin holds the panel per turn; the minimum while scrolling | +| `update_interval` | integer | `60` | Seconds between the core's calls to the plugin's `update()` | -For scrolling text, `display_duration` should be long enough for at least one -full pass, or viewers only ever see the middle of the message. A rough guide: +For scrolling text the plugin asks for a turn long enough for one full pass, +using `display_duration` as the floor and 300 seconds as the ceiling, so a long +message is not cut off mid-way. The turn it asks for is roughly: ```text -seconds for one pass ≈ (text width + panel width + scroll_gap_width) / (scroll_speed / scroll_delay) +seconds ≈ 1.1 × (text width + 3 × panel width + scroll_gap_width) / (scroll_speed / scroll_delay) ``` +Two exceptions: the first turn after a restart can come before the scrolling +strip has been built, and then uses `display_duration` alone; and a duration +set for this mode on the web UI's Rotation & Durations page replaces both. + `update_interval` matters little here because the text is static configuration -rather than fetched data — it only decides how quickly a config change is -picked up. +rather than fetched data. A settings save applies straight away whatever it is +set to. --- @@ -308,8 +313,10 @@ It is moved to the nearest speed the panel can draw in whole pixels — see [How fast it moves](#how-fast-it-moves). **I only ever see the middle of the message.** -`display_duration` is ending the turn before a full pass completes. Raise it, -or shorten the text. +The turn is ending before a full pass completes. The plugin stretches its turn +to one pass (up to 300 seconds), so check whether the Rotation & Durations page +sets a fixed duration for `text_display`, and whether the pass is longer than +300 seconds at your speed; raise the speed or shorten the text if so. **`font_size` has no effect.** Either `font_mode` is `auto` (which chooses the size itself) or `font_path` diff --git a/plugins/text-display/manifest.json b/plugins/text-display/manifest.json index aae06a81..6f0b4c5a 100644 --- a/plugins/text-display/manifest.json +++ b/plugins/text-display/manifest.json @@ -1,7 +1,7 @@ { "id": "text-display", "name": "Text Display", - "version": "1.3.2", + "version": "1.3.3", "author": "ChuckBuilds", "description": "Display custom scrolling or static text with configurable fonts, colors, and scroll speed. Perfect for announcements, messages, or custom displays.", "category": "display", @@ -24,6 +24,12 @@ } }, "versions": [ + { + "version": "1.3.3", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "README only. Scrolling text already stretches its turn to one full pass (display_duration is the floor, 300 seconds the ceiling), so the advice to raise display_duration to fit a pass, and the pass-length formula, were wrong; the timing section now describes what happens, including the Rotation & Durations override. update_interval does not decide how quickly a settings change is picked up: a save applies straight away." + }, { "version": "1.3.2", "released": "2026-09-28", @@ -115,7 +121,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/tide-display/README.md b/plugins/tide-display/README.md index 7dbb2546..3f1562b6 100644 --- a/plugins/tide-display/README.md +++ b/plugins/tide-display/README.md @@ -282,7 +282,7 @@ at the default 6 and `5x7.bdf` silently fell back. ```text tide-display/ ├── manifest.json # Plugin metadata and version history -├── manager.py # TideDisplayPlugin — all four screens +├── manager.py # TidePlugin — all four screens ├── config_schema.json # Settings schema; source of truth for defaults ├── requirements.txt └── README.md diff --git a/plugins/tide-display/manager.py b/plugins/tide-display/manager.py index 0e9d92e5..8d22f54a 100644 --- a/plugins/tide-display/manager.py +++ b/plugins/tide-display/manager.py @@ -427,7 +427,19 @@ def _prune_legacy_daily_keys(self): self._cache_delete(f"{self.plugin_id}:hilo:{self.station_id}:{d}") self._cache_delete(f"{self.plugin_id}:hourly:{self.station_id}:{d}") - def display(self, force_clear=False): + def display(self, display_mode=None, force_clear=False): + # The core rotates through the manifest's four modes and names the one + # it is showing. display() used to ignore the name and step its own + # rotation, so a screen switched off still took a turn (another screen + # was drawn in its slot). A mode that is off now reports no content, + # and the core moves straight on. Without a name (Vegas capture, older + # cores) the plugin keeps rotating on its own. + if display_mode in self.MODES: + if display_mode not in self.modes: + return False + mode = display_mode + else: + mode = self.modes[self.mode_idx % len(self.modes)] dw = self.display_manager.matrix.width dh = self.display_manager.matrix.height canvas = Image.new('RGB', (dw, dh), C_BG) @@ -442,7 +454,7 @@ def display(self, force_clear=False): elif not self.hilo: self._loading(draw, dw, dh, L) else: - m = self.modes[self.mode_idx % len(self.modes)] + m = mode if m == 'current': self._mode_current(canvas, draw, dw, dh, L) elif m == 'schedule': self._mode_schedule(draw, dw, dh, L) elif m == 'chart': self._mode_chart(canvas, draw, dw, dh, L) @@ -720,11 +732,14 @@ def _txt_s(self, x, y, text, color=C_TEXT, small=True): def _no_station(self, draw, dw, dh, L): draw.rectangle([0, 0, dw-1, dh-1], outline=C_BAR_OUT) - header_h, small_h, spacing = 8, 6, 2 - base_y = dh // 2 - 8 - line_y = [base_y + i * (small_h + spacing) + header_h + spacing + # Glyph heights as drawn: the header face is 7px, the small 4x6 face + # 7px with descenders. Sized as 6, the block ran onto the border on a + # 32-row panel and cut off the bottom of "in Settings". + header_h, small_h, spacing = 7, 7, 2 + block_h = header_h + spacing + 1 + small_h + spacing + small_h + base_y = max(1, (dh - block_h) // 2) + line_y = [base_y + header_h + spacing + 1 + i * (small_h + spacing) for i in range(2)] - line_y = [min(y, dh - small_h - 1) for y in line_y] self._txtc(dw//2, base_y, 'TIDE', C_WAVE1, small=False) self._txtc(dw//2, line_y[0], 'Set Station ID', C_LABEL) self._txtc(dw//2, line_y[1], 'in Settings', C_LABEL) diff --git a/plugins/tide-display/manifest.json b/plugins/tide-display/manifest.json index e64c4a70..c11014d6 100644 --- a/plugins/tide-display/manifest.json +++ b/plugins/tide-display/manifest.json @@ -1,7 +1,7 @@ { "id": "tide-display", "name": "Tide Display", - "version": "1.3.4", + "version": "1.3.5", "author": "ChuckBuilds", "description": "Coastal tide display with animated wave level, tide schedule, 24-hour chart, and tidal statistics. Powered by the free NOAA Tides & Currents API (US stations, no API key required).", "entry_point": "manager.py", @@ -25,6 +25,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.3.5", + "released": "2026-10-02", + "ledmatrix_min_version": "2.0.0", + "notes": "A screen switched off no longer takes a turn: display() drew its own rotation and ignored the mode the core asked for, so a disabled screen's slot showed another screen. The setup prompt (no station set) no longer runs into the border on a 32-row panel, which cut off the bottom of 'in Settings'." + }, { "version": "1.3.4", "released": "2026-09-28", @@ -112,7 +118,7 @@ "license": "GPL-3.0", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/tide-display", "config_schema": "config_schema.json", - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/tide-display/test_units_in_cache.py b/plugins/tide-display/test_units_in_cache.py index fa534397..721ca34d 100644 --- a/plugins/tide-display/test_units_in_cache.py +++ b/plugins/tide-display/test_units_in_cache.py @@ -57,6 +57,10 @@ def set(self, key, data, ttl=None): p.logger = logging.getLogger("test-tide") p.cache_manager = DictCache() p.STALE_MAX_DAYS = getattr(TidePlugin, "STALE_MAX_DAYS", 3) +# Pin "today" to the fixture dates below; the staleness check reads the +# real clock, so this case started failing a few days after it was written. +import datetime as _dt +p._today = lambda: _dt.date(2026, 9, 28) calls = [] diff --git a/plugins/ufc-scoreboard/CHANGELOG.md b/plugins/ufc-scoreboard/CHANGELOG.md index e6b4a92a..6089d88b 100644 --- a/plugins/ufc-scoreboard/CHANGELOG.md +++ b/plugins/ufc-scoreboard/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.19.3] - 2026-10-02 + +### Changed +- Root `update_interval` is hidden: the manifest declares `update_interval` + (60 s) and the core scheduler uses that over this setting, so it never did + anything. Each list refreshes at its own `ufc.*_update_interval`. Kept + declared so saved configs keep validating. The README said it set the fetch + cadence. +- README documents `ufc.odds_update_interval` and + `ufc.live_odds_update_interval`. + ## [1.19.2] - 2026-10-02 ### Changed diff --git a/plugins/ufc-scoreboard/README.md b/plugins/ufc-scoreboard/README.md index e29138ab..70904382 100644 --- a/plugins/ufc-scoreboard/README.md +++ b/plugins/ufc-scoreboard/README.md @@ -169,7 +169,7 @@ Defaults are the schema defaults, which is what the web UI writes. | `enabled` | boolean | `true` | Master on/off switch. | | `display_duration` | 5–300 s | `30` | How long the display controller shows this plugin's mode before rotating to the next plugin. | | `game_display_duration` | 3–60 s | `15` | Per-fight time within a mode, where the mode does not override it. | -| `update_interval` | 30–86400 s | `3600` | How often to fetch new data. | +| `update_interval` | 30–86400 s | `3600` | **Hidden (still declared); has no effect.** The manifest's `update_interval` (60 s) wins in the core scheduler, and each list refreshes at its own `ufc.*_update_interval`. | | `timezone` | string | `""` | **Advanced.** IANA zone for event times, e.g. `America/Chicago`. Blank follows the LEDMatrix global timezone, then the host system's, then UTC. | | `schedule_lookback_days` | 1–60 | `14` | **Advanced.** How far back to fetch for the Recent screen. | | `schedule_lookahead_days` | 1–60 | `7` | **Advanced.** How far ahead to fetch for Upcoming. A card beyond this horizon is never fetched. | @@ -218,6 +218,8 @@ Defaults are the schema defaults, which is what the web UI writes. | `ufc.live_update_interval` | 5–300 s | `30` | How often live fight data refreshes. | | `ufc.recent_update_interval` | 60–86400 s | `3600` | **Advanced.** How often the finished-fights list is rebuilt. This also sets how soon a fight that has just ended can appear. | | `ufc.upcoming_update_interval` | 60–86400 s | `3600` | **Advanced.** How often the upcoming-fights list is rebuilt. | +| `ufc.odds_update_interval` | 60–86400 s | `3600` | **Advanced.** How long fetched odds for a fight that has not started are reused before being requested again. | +| `ufc.live_odds_update_interval` | 15–3600 s | `60` | **Advanced.** The same for a fight in progress. | | `ufc.stale_game_timeout` | 60–3600 s | `300` | **Advanced.** Drop a live fight the API has stopped updating. | ### Dynamic duration @@ -323,7 +325,8 @@ can tell *that* a fight is live but not *whose*. ## Data source ESPN's public MMA endpoints. No API key required. Be mindful of -`update_interval` — the default of 3600s suits normal use. +`ufc.recent_update_interval` and `ufc.upcoming_update_interval` — the default +of 3600s suits normal use. The documentation images come from `docs/assets/ufc-scoreboard/shots.json` and re-render with `python scripts/render_docs_assets.py --plugin ufc-scoreboard diff --git a/plugins/ufc-scoreboard/config_schema.json b/plugins/ufc-scoreboard/config_schema.json index 444f34b7..6b994563 100644 --- a/plugins/ufc-scoreboard/config_schema.json +++ b/plugins/ufc-scoreboard/config_schema.json @@ -21,7 +21,9 @@ "default": 3600, "minimum": 30, "maximum": 86400, - "description": "How often to fetch new data in seconds" + "x-advanced": true, + "x-display": "hidden", + "description": "How often to fetch new data in seconds HIDDEN: the manifest declares update_interval (60 s), and the core scheduler uses the manifest's value over this setting; while a fight is live, get_update_interval() answers instead. Fetch cadence comes from ufc.live_update_interval, recent_update_interval and upcoming_update_interval." }, "game_display_duration": { "type": "number", diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 9dfc8e35..c20bb73f 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.19.2", + "version": "1.19.3", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.19.3", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "Hides the root update_interval setting: the manifest's update_interval (60 s) wins in the core scheduler, so it never did anything; each list refreshes at its own ufc.*_update_interval. README said it set the fetch cadence, and now documents ufc.odds_update_interval and live_odds_update_interval. No change in behaviour." + }, { "version": "1.19.2", "released": "2026-10-02", diff --git a/plugins/youtube-stats/manager.py b/plugins/youtube-stats/manager.py index 2cb378da..8540bb07 100644 --- a/plugins/youtube-stats/manager.py +++ b/plugins/youtube-stats/manager.py @@ -228,7 +228,9 @@ def _get_channel_stats(self) -> Optional[Dict[str, Any]]: return None # Try cache first - cache_key = f"{self.plugin_id}_channel_stats" + # Keyed by channel: with one shared key, switching channel_id served + # the previous channel's stats until the old entry aged out. + cache_key = f"{self.plugin_id}_channel_stats_{self.channel_id}" cached = self.cache_manager.get(cache_key, max_age=self.update_interval_config) if cached: self.logger.debug("Using cached channel stats") diff --git a/plugins/youtube-stats/manifest.json b/plugins/youtube-stats/manifest.json index 9ee21c25..70d3ab5b 100644 --- a/plugins/youtube-stats/manifest.json +++ b/plugins/youtube-stats/manifest.json @@ -1,7 +1,7 @@ { "id": "youtube-stats", "name": "YouTube Stats", - "version": "1.3.2", + "version": "1.3.3", "author": "ChuckBuilds", "description": "Display YouTube channel statistics including subscriber count, total views, and channel name on your LED matrix", "category": "social", @@ -33,6 +33,12 @@ } ], "versions": [ + { + "released": "2026-10-02", + "version": "1.3.3", + "changelog": "The cached channel stats are keyed by channel ID. With one shared key, changing channel_id kept showing the previous channel's numbers until the cached entry expired (up to update_interval).", + "ledmatrix_min_version": "3.4.0" + }, { "released": "2026-09-28", "version": "1.3.2", @@ -106,7 +112,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-09-28", + "last_updated": "2026-10-02", "compatible_versions": [ ">=3.4.0" ]