diff --git a/README.md b/README.md index 693d0cab..3905ae7d 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \ | Plugin | Description | Preview | |--------|-------------|---------| -| [News Ticker](./plugins/news/) | RSS news headlines from ESPN, NCAA, custom sources | | +| [News Ticker](./plugins/news/) | RSS news headlines from ESPN, NCAA, custom sources | news on an LED panel | | [Of The Day](./plugins/of-the-day/) | Daily quotes, Bible verses, word of the day | of-the-day on an LED panel | ### Integrations (2) diff --git a/docs/assets/news/colors.png b/docs/assets/news/colors.png new file mode 100644 index 00000000..e5bc9ca8 Binary files /dev/null and b/docs/assets/news/colors.png differ diff --git a/docs/assets/news/fixtures/espn-feeds.json b/docs/assets/news/fixtures/espn-feeds.json new file mode 100644 index 00000000..cb22c854 --- /dev/null +++ b/docs/assets/news/fixtures/espn-feeds.json @@ -0,0 +1 @@ +{"news_MLB_2026090217":[{"feed_name":"MLB","title":"'This is all anonymous?': MLB players unfiltered on salary cap, CBA talks and -- whether there will be a 2027 season","description":"We asked more than two dozen major leaguers to weigh in on the state of baseball's labor negotiations.","published":"Wed, 2 Sep 2026 12:49:13 EST","link":"https://www.espn.com/mlb/story/_/id/49786246/mlb-labor-2026-players-survey-salary-cap-cba-negotiations-lockout-mlbpa","timestamp":"2026-09-02T17:00:00"},{"feed_name":"MLB","title":"Kroenke buying Angels; sale for $4B, sources say","description":"Stan Kroenke has reached an agreement to acquire controlling interest in the Los Angeles Angels from Arte Moreno.","published":"Wed, 2 Sep 2026 12:49:17 EST","link":"https://www.espn.com/mlb/story/_/id/49795578/kroenke-buy-controlling-interest-angels-moreno","timestamp":"2026-09-02T17:00:00"},{"feed_name":"MLB","title":"Trout on 'needed' Angels sale: It's a 'fresh start'","description":"Mike Trout said he was \"excited\" at the news of the Angels' sale to Stan Kroenke, pointing to the new owner's track record when it comes to his other teams.","published":"Wed, 2 Sep 2026 12:49:17 EST","link":"https://www.espn.com/mlb/story/_/id/49798568/mike-trout-needed-sale-angels-fresh-start","timestamp":"2026-09-02T17:00:00"},{"feed_name":"MLB","title":"Padres call up 20-year-old catching prospect Salas","description":"The Padres selected the contract of 20-year-old catcher Ethan Salas from Triple-A El Paso on Tuesday, making him the youngest active player in the major leagues.","published":"Wed, 2 Sep 2026 12:49:17 EST","link":"https://www.espn.com/mlb/story/_/id/49797349/padres-call-20-year-old-catching-prospect-ethan-salas","timestamp":"2026-09-02T17:00:00"},{"feed_name":"MLB","title":"Dodgers' Smith, Rushing, Wrobleski back for push","description":"The Dodgers welcomed back starting catcher Will Smith, backup Dalton Rushing and pitcher Justin Wrobleski from the injured list Tuesday, bolstering their bench and bullpen for the final month of the season.","published":"Wed, 2 Sep 2026 12:49:17 EST","link":"https://www.espn.com/mlb/story/_/id/49800590/dodgers-get-smith-rushing-wrobleski-back-il","timestamp":"2026-09-02T17:00:00"},{"feed_name":"MLB","title":"Dandy debuts for Morales, Simpson key Nats' win","description":"Yohandy Morales had three hits, including a home run, and Jared Simpson threw three perfect innings to pick up the victory, and Washington Nationals, with their expanded roster of minor league call-ups, defeated the Atlanta Braves 9-5 on Tuesday night.","published":"Wed, 2 Sep 2026 12:49:17 EST","link":"https://www.espn.com/mlb/story/_/id/49799673/memorable-debuts-morales-simpson-key-nationals-win","timestamp":"2026-09-02T17:00:00"}],"news_NFL_2026090217":[{"feed_name":"NFL","title":"Facts vs. Feelings: Should you be in or out on these seven players?","description":"In advance of the biggest draft weekend, Liz Loza reveals her strongest thoughts to help you have a successful draft.","published":"Wed, 2 Sep 2026 14:30:32 EST","link":"https://www.espn.com/fantasy/football/story/_/id/49798112/2026-fantasy-football-facts-vs-feelings-ladd-mcconkey-bucky-irving-lamar-jackson-colston-loveland","timestamp":"2026-09-02T17:00:00"},{"feed_name":"NFL","title":"10 bold predictions: Which players will surprise in fantasy?","description":"Our fantasy analysts picked their favorite \"out on a limb\" predictions for the NFL season ahead.","published":"Wed, 2 Sep 2026 14:30:32 EST","link":"https://www.espn.com/fantasy/football/story/_/id/49755632/fantasy-football-2026-rankings-bold-predictions","timestamp":"2026-09-02T17:00:00"},{"feed_name":"NFL","title":"Raiders name Cousins starting QB for Week 1","description":"Kirk Cousins will begin the season as the Raiders' starting quarterback over rookie Fernando Mendoza.","published":"Wed, 2 Sep 2026 16:52:25 EST","link":"https://www.espn.com/nfl/story/_/id/49804802/raiders-name-kirk-cousins-starting-qb-week-1-fernando-mendoza","timestamp":"2026-09-02T17:00:00"},{"feed_name":"NFL","title":"Hurts agrees that he, Brown grew apart: 'Clearly'","description":"Eagles quarterback Jalen Hurts confirmed former teammate A.J. Brown's comments that the two had grown apart last season in QB's interview with GQ magazine.","published":"Wed, 2 Sep 2026 16:52:25 EST","link":"https://www.espn.com/nfl/story/_/id/49804039/eagles-hurts-agrees-brown-grew-apart-last-season","timestamp":"2026-09-02T17:00:00"},{"feed_name":"NFL","title":"49ers star DE Bosa takes big step toward return","description":"49ers defensive end Nick Bosa took a big step toward returning to play Wednesday when he participated in a full practice for the first time since tearing his right anterior cruciate ligament in Week 3 of last season.","published":"Wed, 2 Sep 2026 16:52:26 EST","link":"https://www.espn.com/nfl/story/_/id/49806533/49ers-nick-bosa-practices-fully-first-acl-injury","timestamp":"2026-09-02T17:00:00"},{"feed_name":"NFL","title":"Emmitt Smith, his company cited in $2.5M suit","description":"A Native American development group says Pro Football Hall of Famer Emmitt Smith and his company misappropriated a loan intended to fund a solar farm project in Texas.","published":"Wed, 2 Sep 2026 16:52:26 EST","link":"https://www.espn.com/nfl/story/_/id/49805648/emmitt-smith-company-cited-fraud-claim","timestamp":"2026-09-02T17:00:00"}],"news_TOP SPORTS_2026090217":[{"feed_name":"TOP SPORTS","title":"Clippers lose five first-round picks, fined $30M","description":"Among the punishments announced by the NBA following an investigation into the Clippers: a loss of five first-round draft picks and a fine of $30M. Owner Steve Ballmer is suspended from all league and team activities for one year.","published":"Wed, 2 Sep 2026 16:47:28 EST","link":"https://www.espn.com/nba/story/_/id/49806356/nba-announces-punishments-clippers-kawhi-probe","timestamp":"2026-09-02T17:00:00"},{"feed_name":"TOP SPORTS","title":"Tiger's license suspended 5 years after plea deal","description":"Tiger Woods had his driver's license suspended five years Wednesday after striking a plea deal in his DUI case.","published":"Wed, 2 Sep 2026 17:22:36 EST","link":"https://www.espn.com/golf/story/_/id/49802892/tiger-woods-strikes-plea-deal-driver-license-suspended-5-years","timestamp":"2026-09-02T17:00:00"},{"feed_name":"TOP SPORTS","title":"Georgia AG to SEC: Oust LSU if Kiffin plays pros","description":"Georgia Attorney General Chris Carr is encouraging Greg Sankey to take \"all measures available,\" including suspending and/or removing LSU from the SEC if the Tigers roster former NFL players.","published":"Wed, 2 Sep 2026 17:22:36 EST","link":"https://www.espn.com/college-football/story/_/id/49806514/georgia-ag-sec-take-all-measures-available-lsu-plays-pros","timestamp":"2026-09-02T17:00:00"},{"feed_name":"TOP SPORTS","title":"Carr, Manning top packed Heisman odds boards","description":"Notre Dame quarterback CJ Carr and Texas quarterback Arch Manning are the favorites to win the Heisman Trophy, but the odds at sportsbooks suggest it's a wide-open race.","published":"Wed, 2 Sep 2026 17:22:35 EST","link":"https://www.espn.com/college-football/story/_/id/49804905/cj-carr-arch-manning-lead-congested-heisman-oddsboards","timestamp":"2026-09-02T17:00:00"},{"feed_name":"TOP SPORTS","title":"Vikings GM expects McCarthy on Week 1 roster","description":"Vikings general manager Nolan Teasley said Wednesday he expects quarterback J.J. McCarthy to be a part of the team when the regular season begins.","published":"Wed, 2 Sep 2026 17:22:36 EST","link":"https://www.espn.com/nfl/story/_/id/49805381/vikings-gm-expects-mccarthy-week-1-roster-develop","timestamp":"2026-09-02T17:00:00"},{"feed_name":"TOP SPORTS","title":"49ers star DE Bosa takes big step toward return","description":"49ers defensive end Nick Bosa took a big step toward returning to play Wednesday when he participated in a full practice for the first time since tearing his right anterior cruciate ligament in Week 3 of last season.","published":"Wed, 2 Sep 2026 17:22:36 EST","link":"https://www.espn.com/nfl/story/_/id/49806533/49ers-nick-bosa-practices-fully-first-acl-injury","timestamp":"2026-09-02T17:00:00"}]} \ No newline at end of file diff --git a/docs/assets/news/font-size.png b/docs/assets/news/font-size.png new file mode 100644 index 00000000..7c418719 Binary files /dev/null and b/docs/assets/news/font-size.png differ diff --git a/docs/assets/news/hero.png b/docs/assets/news/hero.png new file mode 100644 index 00000000..514f547b Binary files /dev/null and b/docs/assets/news/hero.png differ diff --git a/docs/assets/news/panel-sizes.png b/docs/assets/news/panel-sizes.png new file mode 100644 index 00000000..7267d6aa Binary files /dev/null and b/docs/assets/news/panel-sizes.png differ diff --git a/docs/assets/news/shots.json b/docs/assets/news/shots.json new file mode 100644 index 00000000..723daca8 --- /dev/null +++ b/docs/assets/news/shots.json @@ -0,0 +1,253 @@ +{ + "plugin": "news", + "defaults": { + "width": 128, + "height": 32, + "scale": 6, + "freeze_time": "2026-09-02T21:00:00+00:00", + "mock_data": "fixtures/espn-feeds.json", + "config": { + "enabled": true, + "feeds": { + "enabled_feeds": [ + "MLB", + "NFL", + "TOP SPORTS" + ] + } + } + }, + "shots": [ + { + "name": "hero", + "frames": 60, + "config": { + "global": { + "font_size": 8 + } + }, + "width": 256, + "scale": 4 + }, + { + "name": "size-12", + "frames": 60, + "config": { + "global": { + "font_size": 12 + } + }, + "standalone": false + }, + { + "name": "size-8", + "frames": 60, + "config": { + "global": { + "font_size": 8 + } + }, + "standalone": false + }, + { + "name": "size-6", + "frames": 60, + "config": { + "global": { + "font_size": 6 + } + }, + "standalone": false + }, + { + "name": "col-default", + "frames": 300, + "config": { + "global": { + "font_size": 8 + } + }, + "width": 256, + "scale": 4, + "standalone": false + }, + { + "name": "col-amber", + "frames": 300, + "config": { + "global": { + "font_size": 8 + }, + "feeds": { + "text_color": [ + 255, + 176, + 0 + ], + "separator_color": [ + 255, + 90, + 0 + ] + } + }, + "width": 256, + "scale": 4, + "standalone": false + }, + { + "name": "col-green", + "frames": 300, + "config": { + "global": { + "font_size": 8 + }, + "feeds": { + "text_color": [ + 0, + 255, + 120 + ], + "separator_color": [ + 0, + 140, + 200 + ] + } + }, + "width": 256, + "scale": 4, + "standalone": false + }, + { + "name": "col-mono", + "frames": 300, + "config": { + "global": { + "font_size": 8 + }, + "feeds": { + "text_color": [ + 255, + 255, + 255 + ], + "separator_color": [ + 255, + 255, + 255 + ] + } + }, + "width": 256, + "scale": 4, + "standalone": false + }, + { + "name": "panel-64", + "frames": 60, + "config": { + "global": { + "font_size": 6 + } + }, + "width": 64, + "scale": 8, + "standalone": false + }, + { + "name": "panel-128", + "frames": 60, + "config": { + "global": { + "font_size": 8 + } + }, + "width": 128, + "scale": 8, + "standalone": false + }, + { + "name": "panel-256", + "frames": 60, + "config": { + "global": { + "font_size": 8 + } + }, + "width": 256, + "scale": 4, + "standalone": false + } + ], + "composites": [ + { + "name": "font-size", + "columns": 1, + "cells": [ + { + "shot": "size-12", + "label": "font_size: 12", + "sublabel": "the default; about ten characters on a 128-wide panel" + }, + { + "shot": "size-8", + "label": "font_size: 8", + "sublabel": "noticeably more headline on screen at once" + }, + { + "shot": "size-6", + "label": "font_size: 6", + "sublabel": "the most text, at the limit of legibility across a room" + } + ] + }, + { + "name": "colors", + "columns": 1, + "cells": [ + { + "shot": "col-default", + "label": "The defaults", + "sublabel": "white headlines, red separator" + }, + { + "shot": "col-amber", + "label": "Amber", + "sublabel": "feeds.text_color and feeds.separator_color" + }, + { + "shot": "col-green", + "label": "Green", + "sublabel": "" + }, + { + "shot": "col-mono", + "label": "All white", + "sublabel": "separator matched to the text" + } + ] + }, + { + "name": "panel-sizes", + "columns": 1, + "cells": [ + { + "shot": "panel-64", + "label": "64 x 32", + "sublabel": "font_size 6; only a few characters at a time" + }, + { + "shot": "panel-128", + "label": "128 x 32", + "sublabel": "font_size 8; the common two-panel chain" + }, + { + "shot": "panel-256", + "label": "256 x 32", + "sublabel": "font_size 8; a headline fragment you can actually read" + } + ] + } + ] +} diff --git a/plugins.json b/plugins.json index c101ef22..faa6c100 100644 --- a/plugins.json +++ b/plugins.json @@ -554,10 +554,10 @@ "plugin_path": "plugins/news", "stars": 0, "downloads": 0, - "last_updated": "2026-08-05", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.3.2" + "latest_version": "1.4.0" }, { "id": "on-air", diff --git a/plugins/news/README.md b/plugins/news/README.md index c4862780..36239b30 100644 --- a/plugins/news/README.md +++ b/plugins/news/README.md @@ -1,381 +1,386 @@ ------------------------------------------------------------------------------------ -### Connect with ChuckBuilds - -- Show support on Youtube: https://www.youtube.com/@ChuckBuilds -- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/ -- Want to chat or need support? Reach out on the ChuckBuilds Discord: https://discord.com/invite/uW36dVAtcT -- Feeling Generous? Support the project: - - Github Sponsorship: https://github.com/sponsors/ChuckBuilds - - Buy Me a Coffee: https://buymeacoffee.com/chuckbuilds - - Ko-fi: https://ko-fi.com/chuckbuilds/ - ------------------------------------------------------------------------------------ - -# News Ticker Plugin - -A plugin for LEDMatrix that displays scrolling news headlines from RSS feeds including sports news from ESPN, NCAA updates, and custom RSS sources. - -## Features - -- **Multiple RSS Sources**: ESPN sports feeds, NCAA updates, and custom RSS URLs -- **Scrolling Headlines**: Continuous scrolling ticker display -- **Headline Paging**: Each turn shows as many headlines as can finish scrolling, then the next turn picks up where the last one stopped — headlines aren't cut off part-way through, and the tail of a long feed list still reaches the panel -- **Pixel-Perfect Text**: Glyphs render 1-bit with anti-aliasing off, so every pixel is fully lit or fully off — no blurred edges on the LED grid -- **News Source Logos**: Display logos for news sources (ESPN, NFL Network, MLB Network, etc.) -- **Headline Rotation**: Cycle through headlines after multiple viewings -- **Custom Feeds**: Add your own RSS feed URLs -- **Sports Focus**: Pre-configured feeds for NFL, NBA, MLB, NCAA, and more -- **Configurable Display**: Adjustable scroll speed, colors, and timing -- **Background Data Fetching**: Efficient RSS parsing without blocking display - -## Configuration - -### Global Settings - -All scrolling/timing/font settings live under the `global.*` namespace -in [`config_schema.json`](config_schema.json) — that file is the source -of truth. The keys you'll touch most often: - -- `global.display_duration`: How long to show the ticker (10–300s, default 30) -- `global.update_interval`: Seconds between RSS fetches (default 300) -- `global.display.scroll_speed`: Pixels per frame (0.5–5.0, default 1.0) -- `global.display.scroll_delay`: Sleep between scroll steps in seconds (0.001–0.1, default 0.01) -- `global.target_fps`: Target frames per second cap (30–200, default 100) -- `global.font_size`: Headline font size (8–20, default 12) -- `global.font_path`: Path to TTF/BDF font (default `assets/fonts/PressStart2P-Regular.ttf`) -- `global.dynamic_duration`: Object — `enabled` (default `true`), - `min_duration_seconds` (default `30`), `max_duration_seconds` - (default `300`), `buffer_ratio` (default `0.1`) -- `global.headline_paging`: Object — `enabled` (default `true`), - `max_headlines_per_page` (default `0` = fit as many as time allows), - `page_hold_seconds` (default `2.0`), `duration_overrun_allowance` - (default `0.25`). See [Headline Paging](#headline-paging). -- `global.rotation_enabled`: Enable headline rotation (default `true`; only - used when `headline_paging.enabled` is `false`) -- `global.rotation_threshold`: Cycles before rotating headlines (1–10, default 3; - only used when `headline_paging.enabled` is `false`) -- `global.headlines_per_feed`: Headlines to fetch per feed (1–10, default 2) -- `global.background_service.*`: Background fetch tuning — - `enabled`, `request_timeout`, `max_retries`, `priority` - -### Feed Settings - -#### Enabled Predefined Feeds +# News Ticker -```json -{ - "feeds": { - "enabled_feeds": ["NFL", "NCAA FB", "NBA", "MLB"] - } -} -``` +A scrolling headline ticker for your LED matrix, fed by RSS. Nine sports feeds +are built in, and it takes any RSS URL you point it at. -#### Custom RSS Feeds +![Real ESPN headlines scrolling across a 256x32 panel](../../docs/assets/news/hero.png) -Custom feeds are configured as an array of feed objects, each with a name, URL, enabled status, and optional logo: +*Every image in this README is real plugin output, rendered at the true panel +size from a recorded ESPN RSS response and then scaled up so the pixels stay +pixels. The headlines are genuine ESPN copy from 2 September 2026.* -```json -{ - "feeds": { - "custom_feeds": [ - { - "name": "Tech News", - "url": "https://example.com/rss.xml", - "enabled": true, - "logo": { - "id": "tech-news-logo", - "path": "plugins/ledmatrix-news/assets/logos/tech-news-logo.png", - "uploaded_at": "2024-01-01T00:00:00Z" - } - }, - { - "name": "Local Sports", - "url": "https://local-sports.com/feed.xml", - "enabled": true - } - ] - } -} -``` +--- + +## Table of Contents + +1. [What's On Screen](#whats-on-screen) +2. [Installation](#installation) +3. [Quick Start](#quick-start) +4. [Choosing Feeds](#choosing-feeds) + - [Built-in feeds](#built-in-feeds) + - [Custom feeds](#custom-feeds) + - [How many headlines you see](#how-many-headlines-you-see) +5. [Scrolling and Timing](#scrolling-and-timing) + - [How fast it moves](#how-fast-it-moves) + - [Dynamic duration and paging](#dynamic-duration-and-paging) +6. [Appearance](#appearance) + - [Font size](#font-size) + - [Fonts](#fonts) + - [Colours and logos](#colours-and-logos) + - [Background fetching](#background-fetching) +7. [Panel Sizes](#panel-sizes) +8. [Troubleshooting](#troubleshooting) +9. [Development](#development) +10. [Support](#support) + +--- -- `name` (required): Feed name (1-100 characters) -- `url` (required): RSS feed URL (must be valid URI) -- `enabled` (optional, default: true): Whether this feed is enabled -- `logo` (optional): Logo file upload object (upload via web UI) +## What's On Screen -**Feed Management:** -- Enable/disable individual feeds using the `enabled` field -- Feeds are processed in the order they appear in the `custom_feeds` array -- Upload custom logos directly via the web UI (similar to static-image plugin) -- Maximum 50 custom feeds allowed +A single strip of headlines scrolling right to left, with a coloured separator +between them. Headlines are pulled from every enabled feed, interleaved, and +drawn as one continuous strip — so the panel is never blank between items. -#### Display Colors +Because it scrolls, **the first moment of a turn is legitimately an empty +panel**: the strip starts fully off the right edge and travels in. + +--- + +## Installation + +**From the Plugin Store (recommended).** Open the LEDMatrix web interface at +`http://:5000`, go to **Plugin Manager**, find **News Ticker** in +the **Plugin Store** section, and click **Install**. + +**Manually.** Copy this directory into your LEDMatrix `plugin-repos/` and +restart the display service. + +> **A fresh install shows nothing.** `enabled` defaults to `false`, and +> `feeds.enabled_feeds` defaults to an **empty list** — so even switched on, +> there is nothing to fetch until you pick at least one feed. + +--- + +## Quick Start ```json { - "feeds": { - "text_color": [255, 255, 255], - "separator_color": [255, 0, 0] + "news": { + "enabled": true, + "feeds": { "enabled_feeds": ["TOP SPORTS", "MLB"] } } } ``` -#### News Source Logos +A fuller configuration: ```json { - "feeds": { - "show_logos": true, - "logo_size": 28, - "custom_feeds": [ - { - "name": "Tech News", - "url": "https://example.com/rss.xml", - "enabled": true, - "logo": { - "id": "tech-news-logo", - "path": "plugins/ledmatrix-news/assets/logos/tech-news-logo.png", - "uploaded_at": "2024-01-01T00:00:00Z" - } - } - ] + "news": { + "enabled": true, + "feeds": { + "enabled_feeds": ["MLB", "NFL", "TOP SPORTS"], + "text_color": [255, 255, 255], + "separator_color": [255, 0, 0], + "show_logos": true, + "logo_size": 28 + }, + "global": { + "display_duration": 30, + "update_interval": 300, + "font_size": 8, + "headlines_per_feed": 2, + "rotation_enabled": true, + "rotation_threshold": 3 + } } } ``` -- `show_logos`: Enable/disable news source logos (default: true) -- `logo_size`: Logo size in pixels (default: display height - 4 pixels) -- `logo`: Optional logo object in each custom feed (upload via web UI file upload widget) - -**Logo Behavior:** -- When a logo is present: Logo replaces the "[Feed Name]: " prefix and " • " separator - - Format: `[Logo] Title` -- When a logo is missing: Shows original format with feed name and separator - - Format: `[Feed Name]: Title • ` - -**Logo Resolution Priority:** -1. Integrated logo from feed object (`logo.path` field) - **New format** -2. Predefined feed mappings (ESPN, NFL Network, MLB Network) -3. Inferred from feed name (checks for "espn", "nfl", "mlb", etc.) -4. Normalized feed name as filename (fallback) - -**Logo Directory Search Order:** -1. Uploaded logo path from feed object (if present) -2. `assets/news_logos/` (primary location for news source logos) -3. `assets/broadcast_logos/` (fallback for broadcast network logos) -4. Plugin `assets/logos/` (plugin-specific logos) - -**Adding Custom Logos:** -1. Upload logo via the web UI file upload widget in the feed configuration -2. The logo will be automatically associated with the feed -3. Logos are stored in the plugin's asset directory - -**Default Feed Mappings (Predefined Feeds):** -- ESPN feeds (NFL, NBA, NHL, NCAA, etc.) → `espn.png` -- MLB feed → `mlbn.png` -- NFL feed → `nfln.png` -- Custom feeds → Use uploaded logo or inferred from name - -## Available Predefined Feeds - -The plugin includes these predefined RSS feeds: - -- **MLB**: ESPN MLB News (`http://espn.com/espn/rss/mlb/news`) -- **NFL**: ESPN NFL News (`http://espn.go.com/espn/rss/nfl/news`) -- **NCAA FB**: ESPN NCAA Football News (`https://www.espn.com/espn/rss/ncf/news`) -- **NHL**: ESPN NHL News (`https://www.espn.com/espn/rss/nhl/news`) -- **NBA**: ESPN NBA News (`https://www.espn.com/espn/rss/nba/news`) -- **TOP SPORTS**: ESPN Top Sports News (`https://www.espn.com/espn/rss/news`) -- **BIG10**: Big Ten Conference News (`https://www.espn.com/blog/feed?blog=bigten`) -- **NCAA**: ESPN NCAA News (`https://www.espn.com/espn/rss/ncaa/news`) -- **Other**: Alternative Sports News (`https://www.coveringthecorner.com/rss/current.xml`) - -## Display Format - -The news ticker displays information in a scrolling format showing: - -- **Feed Source**: Name of the RSS feed (e.g., "NFL", "ESPN") — replaced by the - feed's logo when one is available -- **Headline**: Full news headline text, never abbreviated -- **Separator**: Visual separator between headlines (shown when there's no logo) - -## Pixel-Perfect Text - -All text is drawn with anti-aliasing disabled (`fontmode = "1"`), so every -pixel is either fully lit or fully off. PIL anti-aliases by default, which -blends glyph edges into dim partial-lit pixels — on a 1:1 LED matrix those read -as blur rather than as smoothing. - -This matters most at font sizes that don't land on the font's design grid. Press -Start 2P is drawn on an 8px grid: at size 8 or 16 it happens to align and stays -crisp either way, but at the default size of 12 a single headline picks up -roughly 150 blended pixels with anti-aliasing left on. Sizes on the grid (8, 16, -24) also give the most even stroke widths, so they're worth preferring if you -want the sharpest possible result. - -Nothing to configure — it applies to headlines, feed labels, separators and the -fallback screens alike. - -## Headline Paging - -Headlines are never shortened, so a full set of feeds can easily produce a strip -of scrolling text several minutes long — far more than the display controller -will keep any one plugin on screen. Paging solves that. - -Before each turn the plugin measures how much text can actually finish scrolling -in the time it will be given, fills the strip up to that point, and defers the -rest. When the strip finishes, the next turn resumes at the first headline that -didn't fit. Nothing is drawn that can't be read to the end, and every headline -reaches the panel in a few turns instead of the list's tail never being seen. - -The time budget is derived from the scroll rate and the shortest ceiling that -will be enforced — the plugin's own `dynamic_duration.max_duration_seconds` and -the core's `display.dynamic_duration.max_duration_seconds`, whichever is lower. - -Tuning: - -- `enabled` (default `true`) — set `false` to go back to one long strip - containing every headline, paced by `rotation_enabled`/`rotation_threshold` -- `max_headlines_per_page` (default `0`) — cap headlines per turn regardless of - available time; `0` fits as many as will scroll -- `page_hold_seconds` (default `2.0`) — how long the finished strip stays on - screen before the next page starts, when the ticker isn't rotated away (for - example when News is the only enabled display mode) -- `duration_overrun_allowance` (default `0.25`) — extra time requested from the - controller so a scroll running behind its nominal speed still reaches the end. - Raise it if headlines still get clipped on a heavily loaded Pi. - -## Background Service - -The plugin uses background data fetching for efficient RSS parsing: - -- Requests timeout after 30 seconds (configurable) -- Up to 3 retries for failed requests -- Priority level 2 (medium priority) -- Updates every 5 minutes by default (configurable) - -## Adding Custom Feeds +--- + +## Choosing Feeds + +### Built-in feeds -You can add custom RSS feeds by specifying them in the configuration using the array format: +`feeds.enabled_feeds` takes any of these names: + +| Name | Source | +|------|--------| +| `TOP SPORTS` | ESPN top headlines | +| `MLB` | ESPN MLB | +| `NFL` | ESPN NFL | +| `NBA` | ESPN NBA | +| `NHL` | ESPN NHL | +| `NCAA FB` | ESPN college football | +| `NCAA` | ESPN college sports | +| `BIG10` | A Google News search for Big Ten football | +| `Other` | Covering the Corner (Cleveland Guardians) | + +`BIG10` is a Google News query rather than an official feed, because +`btn.com/feed/` returns HTML and the ESPN Big Ten blog feed was retired. It is +therefore looser in scope than the other entries. + +### Custom feeds + +Any RSS URL works. `feeds.custom_feeds` takes a list of objects: ```json { "feeds": { "custom_feeds": [ - { - "name": "My Sports", - "url": "https://mysportsfeed.com/rss", - "enabled": true - }, - { - "name": "Local News", - "url": "https://localnews.com/sports.xml", - "enabled": true - } + { "name": "Local", "url": "https://example.com/rss", "enabled": true } ] } } ``` -**Note:** The old dictionary format (`{"Feed Name": "URL"}`) is deprecated but still supported for backward compatibility. It will be automatically migrated to the new array format on first load. We recommend using the new array format for new configurations. +| Key | What it does | +|-----|--------------| +| `name` | Shown as the source label and used in the cache key | +| `url` | The RSS URL. Standard RSS `` elements with `` are required | +| `enabled` | Include this feed | +| `logo` | Optional logo for the feed. An object with a `path` to an image file | -## Data Processing +The parser reads `title`, `description`, `pubDate` and `link` from each +`<item>`, unescapes HTML entities, and tidies whitespace. Atom-only feeds +without `<item>` elements will fetch successfully and yield nothing. -- **RSS Parsing**: Uses Python's xml.etree.ElementTree for reliable parsing -- **Text Cleaning**: Removes HTML entities and extra whitespace -- **Length Limiting**: Truncates long headlines for display -- **Caching**: Stores headlines for 10 minutes to avoid excessive API calls +### How many headlines you see -## Dependencies +| Option | Default | What it does | +|--------|---------|--------------| +| `global.headlines_per_feed` | `2` | Headlines taken from each enabled feed per fetch | +| `global.rotation_enabled` | `true` | Rotate which feeds appear when many are enabled | +| `global.rotation_threshold` | `3` | Number of feeds above which rotation kicks in | -This plugin requires the main LEDMatrix installation and uses the cache manager for data storage. +With three feeds and the default `headlines_per_feed: 2`, a cycle carries six +headlines. Raising it makes the strip longer, and therefore each lap slower — +the same trade as any ticker. -## Installation +--- + +## Scrolling and Timing + +| Option | Default | What it does | +|--------|---------|--------------| +| `global.display_duration` | `30` | Seconds the ticker holds the panel per turn | +| `global.update_interval` | `300` | Seconds between feed fetches | +| `global.display.scroll_speed` | `1.0` | Pixels moved per step | +| `global.display.scroll_delay` | `0.01` | Seconds per step | +| `global.target_fps` | `100` | Target frame rate | + +### How fast it moves + +As with the scrolling-text plugin, the rate is: + +```text +pixels per second = scroll_speed / scroll_delay +``` + +The defaults — 1 pixel every 0.01s — give 100 px/s, which the plugin logs on +startup so you can check what it actually resolved to. + +### Dynamic duration and paging + +Two features stop a long strip from being cut off mid-headline. + +**`global.dynamic_duration`** sizes the turn to the content instead of using a +fixed `display_duration`. It accepts `true`/`false` or an object: + +| Key | Default | What it does | +|-----|---------|--------------| +| `enabled` | `true` | Size the turn to how long one full pass takes | +| `min_duration_seconds` | `30` | Never shorter than this | +| `max_duration_seconds` | `300` | Never longer than this | +| `buffer_ratio` | `0.1` | Extra headroom added to the computed time | + +**`global.headline_paging`** splits a strip too long for one turn into pages, +so each headline gets fully seen across successive turns rather than the tail +never appearing. + +| Key | Default | What it does | +|-----|---------|--------------| +| `enabled` | `true` | Break a long strip into pages | +| `max_headlines_per_page` | `0` | `0` means auto — as many as fit the time budget | +| `page_hold_seconds` | `2.0` | Pause at a page boundary | +| `duration_overrun_allowance` | `0.25` | Fraction of overrun tolerated before splitting again | + +Together these mean you rarely need to tune `display_duration` by hand: the +plugin works out how long a pass takes and asks for that much time. -1. Copy this plugin directory to your `ledmatrix-plugins/plugins/` folder -2. Ensure the plugin is enabled in your LEDMatrix configuration -3. Configure your preferred RSS feeds and display options -4. Restart LEDMatrix to load the new plugin +--- + +## Appearance + +### Font size + +`global.font_size` (default `12`) is the biggest lever on how much headline is +readable at once. + +![Three 128x32 panels at font_size 12, 8 and 6](../../docs/assets/news/font-size.png) + +At the default 12 on a 128-wide panel only about ten characters are on screen +at a time, which reads more like a stream of letters than a headline. Dropping +to 8 roughly doubles it. This is the setting to change first if the ticker feels +unreadable. + +### Fonts + +`customization.headline_text` and `customization.source_text` each take a +`font`, `font_size` and (for the source) `text_color`. Five faces are +available and all five render distinctly: + +| Font | Kind | Notes | +|------|------|-------| +| `PressStart2P-Regular.ttf` | Scalable | The default; chunky and very legible | +| `4x6-font.ttf` | Scalable | Fits far more text per line | +| `5by7.regular.ttf` | Scalable | A rounder 5×7 face | +| `5x7.bdf` | Bitmap | Crisp; drawn at its native 7px | +| `4x6.bdf` | Bitmap | The smallest; native 6px | + +**`font_size` only affects the scalable faces.** A `.bdf` is a bitmap font that +exists at exactly one pixel size, so it is drawn at the size its file declares +and ignores `font_size`. + +`global.font_path` (default `assets/fonts/PressStart2P-Regular.ttf`) sets the +face used when no `customization.headline_text.font` is given. It takes a path +rather than a name, resolved relative to the LEDMatrix project root, so it can +point at a font that is not in the picker. `customization` wins where both are +set. + +### Colours and logos + +| Option | Default | What it does | +|--------|---------|--------------| +| `feeds.text_color` | `[255, 255, 255]` | Headline colour | +| `feeds.separator_color` | `[255, 0, 0]` | Colour of the mark between headlines | +| `feeds.show_logos` | `true` | Draw each feed's logo before its headlines | +| `feeds.logo_size` | `28` | Logo height in pixels | +| `customization.source_text.text_color` | `[150, 150, 150]` | The source label | + +![Four 256x32 panels showing white, amber, green and all-white +tickers](../../docs/assets/news/colors.png) + +The separator is visible at the right edge of each panel above — it is the mark +that keeps two headlines from reading as one sentence, so a colour that +contrasts with `text_color` is worth keeping. + +--- + +### Background fetching + +Feeds are fetched on a background thread so the panel never stalls on a slow +server. Under `global.background_service`: + +| Option | Default | What it does | +|--------|---------|--------------| +| `enabled` | `true` | Fetch in the background rather than inline | +| `request_timeout` | `30` | Seconds before a feed request gives up | +| `max_retries` | `3` | Retries per failed feed | +| `priority` | `2` | Queue priority against other plugins' fetches | + +A feed that times out is skipped for that cycle rather than blocking the +others, so one dead source does not empty the ticker. + +--- + +## Panel Sizes + +![The ticker on 64x32, 128x32 and 256x32 panels](../../docs/assets/news/panel-sizes.png) + +Width matters more here than for any other plugin in this repo, because the +ticker's usefulness is how much of a headline you can take in at a glance: + +- **64×32** shows a few characters at a time even at `font_size: 6`. Legible, + but you read it letter by letter. +- **128×32** is workable at `font_size: 8`. +- **256×32** is where a ticker starts to feel like one — a readable fragment of + a real headline sits on the panel at once. + +If you are choosing a panel with a news ticker in mind, buy width. + +--- ## Troubleshooting -- **No headlines showing**: Check if feeds are enabled and URLs are accessible -- **RSS parsing errors**: Verify feed URLs are valid and return proper XML -- **Slow scrolling**: Adjust scroll speed and delay settings -- **Network errors**: Check your internet connection and RSS server availability -- **Blurry or muddy text**: Text is rendered 1-bit, so blur usually means the - font size sits off the font's design grid and stroke widths are uneven. Try a - multiple of 8 for Press Start 2P (`global.font_size`: 8, 16 or 24). -- **Headlines still cut off mid-word**: The scroll is running behind its nominal - speed. Raise `global.headline_paging.duration_overrun_allowance`, or lower - `global.display.scroll_speed` so less content is packed into each turn. -- **Only ever seeing the same first few headlines**: Confirm - `global.headline_paging.enabled` is `true` — with paging off, the legacy - rotation only advances one headline every `rotation_threshold` cycles. -- **Fewer headlines per turn than expected**: Each turn is sized to the shortest - enforced duration cap. Raise `global.dynamic_duration.max_duration_seconds` - *and* the core's `display.dynamic_duration.max_duration_seconds` (default - 180s) — the lower of the two wins. - -## Advanced Features - -- **Pixel-Perfect Text**: 1-bit glyph rendering with anti-aliasing disabled, so nothing renders half-lit -- **Headline Paging**: Sizes each turn to what can actually finish scrolling, then resumes where it left off -- **Headline Rotation**: Legacy fallback that rotates headlines after multiple cycles when paging is disabled -- **Dynamic Duration**: Holds the display until the strip has scrolled to the end, rather than cutting at a fixed time -- **Color Customization**: Configure text and separator colors -- **Font Sizing**: Adjustable font size for readability -- **Feed Prioritization**: Control which feeds are displayed and in what order - -## Performance Notes - -- The plugin uses the **ScrollHelper API** for high-performance scrolling with numpy array optimization -- **Frame-based scrolling** provides smooth, consistent scrolling at 100+ FPS -- **Dynamic duration** automatically calculates display time based on content width and scroll speed -- RSS parsing happens in background to avoid blocking the display -- Configurable update intervals balance freshness vs. network load -- Caching reduces unnecessary network requests - -## Scrolling Implementation - -This plugin uses the LEDMatrix **ScrollHelper** API for optimized scrolling: - -- **High-FPS Mode**: Automatically enables high frame rate (100+ FPS) for smooth scrolling -- **Frame-Based Scrolling**: Uses `display.scroll_speed` (pixels per frame) and `display.scroll_delay` (seconds per frame) for precise control -- **Dynamic Duration**: Automatically calculates display duration based on content width, ensuring all headlines are shown -- **Numpy Optimization**: Uses fast numpy array slicing for minimal CPU usage -- **Smooth Animation**: Pre-allocated buffers and optimized rendering for consistent performance - -### Recommended Configuration - -For best performance, use the frame-based scrolling format: +**Nothing appears at all.** +`enabled` defaults to `false`, and `feeds.enabled_feeds` defaults to an empty +list. Both need setting. -```json -{ - "global": { - "display": { - "scroll_speed": 1.0, - "scroll_delay": 0.01 - }, - "target_fps": 100, - "dynamic_duration": { - "enabled": true, - "min_duration_seconds": 30, - "max_duration_seconds": 300, - "buffer_ratio": 0.1 - }, - "headline_paging": { - "enabled": true, - "max_headlines_per_page": 0, - "page_hold_seconds": 2.0, - "duration_overrun_allowance": 0.25 - } - } -} +**The panel is blank for a moment when the ticker's turn starts.** +Expected — the strip begins off the right edge and travels in. + +**A feed shows no headlines.** +Check the log for a fetch error. The parser needs standard RSS `<item>` +elements; an Atom-only feed fetches fine and yields nothing. `Other` and +`BIG10` point at third-party sources that can change or disappear. + +**I can't read the headlines.** +Lower `global.font_size` — the default of 12 is large for a 128-wide panel. +See [Font size](#font-size). + +**The end of the strip never appears.** +That is what `headline_paging` exists for; check it is enabled. Alternatively +leave `dynamic_duration` on so the turn is sized to the content. + +**I picked a font and nothing changed.** +On a current version all five faces work. Older versions offered `cozette.bdf`, +which had no font file at all, and both `.bdf` faces silently fell back to the +default because they were requested at `font_size` rather than at their own +pixel size. + +**Headlines are stale.** +`global.update_interval` (default 300s) sets the fetch cadence, and results are +cached per feed per hour. A feed that publishes rarely will simply repeat. + +--- + +## Development + +### Project structure + +```text +news/ +├── manifest.json # Plugin metadata and version history +├── manager.py # NewsTickerPlugin +├── config_schema.json # Settings schema; source of truth for defaults +├── requirements.txt +├── test_news_ticker.py +├── test_unchanged_headlines_keep_strip.py +└── README.md ``` -This configuration provides: -- 1 pixel per frame scrolling -- 0.01 second delay = 100 FPS effective rate -- Dynamic duration that adjusts to content length -- Smooth, consistent scrolling performance +### Dependencies + +`requests` for fetching and Pillow for drawing, both already provided by the +LEDMatrix core — `requirements.txt` lists them as comments rather than pins for +exactly that reason. RSS is parsed with Python's built-in +`xml.etree.ElementTree`, so there is no feed-parser dependency to install. + +Headlines are cached under `news_<feed>_<YYYYMMDDHH>`, so the cache key rolls +over hourly and a restart within the same hour reuses what was already fetched. + +### Regenerating the images in this README + +```bash +python scripts/render_docs_assets.py --plugin news +``` + +The fixture under `docs/assets/news/fixtures/` is a recorded ESPN RSS response, +so the images are real data and reproducible. Because the ticker scrolls, the +shot list advances the plugin a number of frames before capturing — a single +frame would be the empty panel the strip starts from. + +--- + +## Support + +- YouTube: <https://www.youtube.com/@ChuckBuilds> +- Instagram: <https://www.instagram.com/ChuckBuilds/> +- Discord: <https://discord.com/invite/uW36dVAtcT> +- Sponsor: [GitHub Sponsors](https://github.com/sponsors/ChuckBuilds) · + [Buy Me a Coffee](https://buymeacoffee.com/chuckbuilds) · + [Ko-fi](https://ko-fi.com/chuckbuilds/) + +Released under the GNU General Public License v3.0 — see [LICENSE](LICENSE). diff --git a/plugins/news/config_schema.json b/plugins/news/config_schema.json index f804fe93..aeedd248 100644 --- a/plugins/news/config_schema.json +++ b/plugins/news/config_schema.json @@ -73,7 +73,10 @@ "x-advanced": true }, "dynamic_duration": { - "type": ["boolean", "object"], + "type": [ + "boolean", + "object" + ], "description": "Dynamic duration settings for synchronising display time with content width", "default": { "enabled": true, @@ -241,7 +244,17 @@ }, "items": { "type": "string", - "enum": ["MLB", "NFL", "NCAA FB", "NHL", "NBA", "TOP SPORTS", "BIG10", "NCAA", "Other"], + "enum": [ + "MLB", + "NFL", + "NCAA FB", + "NHL", + "NBA", + "TOP SPORTS", + "BIG10", + "NCAA", + "Other" + ], "description": "Predefined RSS feed categories" }, "uniqueItems": true, @@ -285,7 +298,11 @@ "endpoint": "/api/v3/plugins/assets/upload", "plugin_id": "news", "max_files": 1, - "allowed_types": ["image/png", "image/jpeg", "image/bmp"], + "allowed_types": [ + "image/png", + "image/jpeg", + "image/bmp" + ], "max_size_mb": 2 }, "description": "Optional logo for this feed. Upload via the file upload widget.", @@ -304,10 +321,16 @@ "description": "Upload timestamp" } }, - "required": ["id", "path"] + "required": [ + "id", + "path" + ] } }, - "required": ["name", "url"], + "required": [ + "name", + "url" + ], "additionalProperties": false } }, @@ -321,7 +344,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 255, 255], + "default": [ + 255, + 255, + 255 + ], "description": "RGB color for headline text" }, "separator_color": { @@ -334,7 +361,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "description": "RGB color for separators between headlines", "x-advanced": true }, @@ -373,8 +404,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true @@ -389,7 +419,10 @@ "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "source_text": { @@ -406,8 +439,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true @@ -432,17 +464,30 @@ }, "minItems": 3, "maxItems": 3, - "default": [150, 150, 150] + "default": [ + 150, + 150, + 150 + ] } }, - "x-propertyOrder": ["font", "font_size", "text_color"], + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], "additionalProperties": false } }, - "x-propertyOrder": ["headline_text", "source_text"], + "x-propertyOrder": [ + "headline_text", + "source_text" + ], "additionalProperties": false } }, "additionalProperties": false, - "required": ["enabled"] + "required": [ + "enabled" + ] } diff --git a/plugins/news/manager.py b/plugins/news/manager.py index 879f58c0..12db5316 100644 --- a/plugins/news/manager.py +++ b/plugins/news/manager.py @@ -361,12 +361,42 @@ def _load_element_font(self, element_cfg: Dict[str, Any], font = ImageFont.truetype(path, size) break except Exception as e: + # A .bdf is a bitmap face and exists at exactly one pixel + # size; FreeType rejects any other with "invalid pixel + # size". Asking for font_size therefore failed for every + # bitmap face in the picker, and the ticker silently fell + # back to the default -- so choosing one appeared to do + # nothing. Retry at the size the file declares. + native = self._bdf_pixel_size(path) + if native is not None and native != size: + try: + font = ImageFont.truetype(path, native) + self.logger.debug( + "Loaded bitmap font %s at its native size %d " + "(requested %d)", name, native, size) + break + except Exception: + pass self.logger.warning(f"Could not load font {name}@{size}: {e}") if font is None and not any(os.path.exists(p) for p in candidates): self.logger.warning(f"Font file not found: {name}; using default font") self._font_cache[key] = font return self._font_cache[key] + @staticmethod + def _bdf_pixel_size(path): + """The pixel size a .bdf font declares, or None if it does not.""" + try: + with open(path, "r", encoding="latin-1") as handle: + for line in handle: + if line.startswith("PIXEL_SIZE"): + return int(line.split()[1]) + if line.startswith("CHARS"): + break # past the header + except (OSError, ValueError, IndexError): + return None + return None + def _apply_font_customization(self, customization: Dict[str, Any]) -> None: """Apply the `customization` block's per-element font overrides. diff --git a/plugins/news/manifest.json b/plugins/news/manifest.json index 6cc79212..c834f502 100644 --- a/plugins/news/manifest.json +++ b/plugins/news/manifest.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News Ticker", - "version": "1.3.2", + "version": "1.4.0", "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": [ + { + "version": "1.4.0", + "released": "2026-09-02", + "ledmatrix_min_version": "2.0.0", + "notes": "Make every font in the picker work, and document every setting. Three of the six font faces rendered identically to the default: cozette.bdf has no font file anywhere, and both .bdf faces failed because a bitmap font exists at exactly one pixel size while the loader asked for font_size. Bitmap faces are now loaded at the size the file's PIXEL_SIZE header declares, and cozette.bdf is removed from the pickers -- the same fix already landed for clock-simple, which shared this code. Verified by hashing renders: before, four of six were the same image; after, all five remaining faces are distinct. The README is rewritten around real recorded ESPN headlines and covers all 31 settings, including that a fresh install shows nothing because enabled_feeds is empty by default, and that font_size is the first thing to lower if the ticker reads as a stream of letters rather than a headline." + }, { "version": "1.3.2", "released": "2026-08-14", @@ -94,7 +100,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-08-05", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", "display_modes": [ diff --git a/scripts/docs_render_support/_docs_frame_runner.py b/scripts/docs_render_support/_docs_frame_runner.py new file mode 100644 index 00000000..bf15a2b1 --- /dev/null +++ b/scripts/docs_render_support/_docs_frame_runner.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Render a plugin after advancing it a number of frames. + +The core's ``scripts/render_plugin.py`` calls ``display()`` once, which is the +right thing for a static screen. A scrolling plugin, though, starts its strip +fully off the right edge, so the first frame is legitimately an empty panel -- +and a screenshot of one says nothing about the plugin. + +This runner drives the same core testing API render_plugin.py uses +(``build_full_config`` / ``PluginLoader`` / ``VisualTestDisplayManager``), then +calls ``display()`` repeatedly before snapshotting, so a ticker can be captured +mid-travel with real content on screen. + +Invoked by scripts/render_docs_assets.py for shots that set ``frames``; it is +not meant to be run by hand. +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +os.environ.setdefault("EMULATOR", "true") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plugin", required=True) + parser.add_argument("--plugin-dir", required=True) + parser.add_argument("--core-repo", required=True) + parser.add_argument("--config", default="{}") + parser.add_argument("--mock-data", default=None) + parser.add_argument("--width", type=int, default=128) + parser.add_argument("--height", type=int, default=32) + parser.add_argument("--frames", type=int, default=1) + parser.add_argument("--frame-seconds", type=float, default=0.05, + help="Frozen-clock seconds to advance between frames") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + sys.path.insert(0, args.core_repo) + from src.plugin_system.testing.loading import ( # noqa: E402 + build_full_config, find_plugin_dir, load_manifest, + ) + from src.plugin_system.testing import ( # noqa: E402 + VisualTestDisplayManager, MockCacheManager, MockPluginManager, + ) + from src.plugin_system.plugin_loader import PluginLoader # noqa: E402 + + plugin_dir = find_plugin_dir(args.plugin, [args.plugin_dir]) + if not plugin_dir: + sys.stderr.write(f"Plugin {args.plugin!r} not found in {args.plugin_dir}\n") + return 1 + plugin_dir = Path(plugin_dir) + + config = build_full_config(plugin_dir, cli_config=json.loads(args.config)) + display_manager = VisualTestDisplayManager(width=args.width, height=args.height) + cache_manager = MockCacheManager() + if args.mock_data: + with open(args.mock_data, "r", encoding="utf-8") as handle: + for key, value in json.load(handle).items(): + cache_manager.set(key, value) + + instance, _module = PluginLoader().load_plugin( + plugin_id=args.plugin, + manifest=load_manifest(plugin_dir), + plugin_dir=plugin_dir, + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + plugin_manager=MockPluginManager(), + install_deps=False, + ) + + try: + instance.update() + except Exception as exc: # matches render_plugin.py: update failures are not fatal + sys.stderr.write(f"update() raised: {exc} -- continuing to display()\n") + + # Animation is driven by elapsed wall-clock time, not by how many times + # display() was called, so the frozen clock has to move between frames or + # every step renders the same first frame. + advance = getattr(__builtins__, "__ledmatrix_docs_advance_clock__", None) + if advance is None and isinstance(__builtins__, dict): + advance = __builtins__.get("__ledmatrix_docs_advance_clock__") + + # force_clear only on the first frame, so a plugin that treats it as "start + # over" does not restart its scroll on every step. + for frame in range(max(1, args.frames)): + instance.display(force_clear=(frame == 0)) + if advance is not None: + advance(args.frame_seconds) + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + display_manager.save_snapshot(str(output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/docs_render_support/sitecustomize.py b/scripts/docs_render_support/sitecustomize.py index 20844d5e..0d503f47 100644 --- a/scripts/docs_render_support/sitecustomize.py +++ b/scripts/docs_render_support/sitecustomize.py @@ -25,25 +25,46 @@ _instant = _instant.replace(tzinfo=_datetime_module.timezone.utc) _timestamp = _instant.timestamp() + # Held in a one-element list so advance() can move it without rebinding a + # closure variable the classmethods below already captured. + _offset = [0.0] + + def _current(): + return _instant + _datetime_module.timedelta(seconds=_offset[0]) + class _FrozenDateTime(_RealDateTime): - """A datetime whose idea of "now" never moves.""" + """A datetime whose "now" only moves when advance() says so.""" @classmethod def now(cls, tz=None): if tz is None: - return _instant.astimezone().replace(tzinfo=None) - return _instant.astimezone(tz) + return _current().astimezone().replace(tzinfo=None) + return _current().astimezone(tz) @classmethod def utcnow(cls): - return _instant.astimezone(_datetime_module.timezone.utc).replace(tzinfo=None) + return _current().astimezone(_datetime_module.timezone.utc).replace(tzinfo=None) @classmethod def today(cls): return cls.now() _datetime_module.datetime = _FrozenDateTime - _time_module.time = lambda: _timestamp + _time_module.time = lambda: _timestamp + _offset[0] + + def advance(seconds): + """Move the frozen clock forward. + + Animation is usually driven by elapsed wall-clock time rather than by + how many times display() was called, so stepping frames against a + clock that never moves renders the same first frame forever. The + documentation frame runner calls this between frames. + """ + _offset[0] += float(seconds) + + # Published for the frame runner; harmless if nothing imports it. + import builtins as _builtins + _builtins.__ledmatrix_docs_advance_clock__ = advance # Recorded HTTP responses, for managers that fetch without reading the cache. try: diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py index 42a1d93d..fb8dd994 100644 --- a/scripts/render_docs_assets.py +++ b/scripts/render_docs_assets.py @@ -55,7 +55,8 @@ (inline object, or a path relative to the shot list), ``skip_update``, ``freeze_time`` (ISO-8601 instant; pins "now" so the image is reproducible), ``http_replay`` (a recorded-responses file, for managers that fetch without -reading the cache) and +reading the cache), ``frames`` (advance a scrolling plugin this many display +steps before snapshotting) and ``env`` (extra environment variables for the render subprocess). Anything omitted falls back to ``defaults``. Set ``"standalone": false`` on a shot that only exists to be pasted into a composite. @@ -232,15 +233,26 @@ def render_shot( config = deep_merge(defaults.get("config", {}), shot.get("config", {})) raw_path = tmpdir / f"{name}-raw.png" - renderer = core_repo / "scripts" / "render_plugin.py" + frames = int(shot.get("frames", defaults.get("frames", 1))) + + # A scrolling plugin starts its strip off the right edge, so one frame is an + # empty panel. Those shots go through our own runner, which drives the same + # core testing API and steps display() before snapshotting. + if frames > 1: + renderer = Path(__file__).resolve().parent / "docs_render_support" / "_docs_frame_runner.py" + extra = ["--core-repo", str(core_repo), "--frames", str(frames)] + else: + renderer = core_repo / "scripts" / "render_plugin.py" + extra = [] if not renderer.is_file(): - raise SystemExit(f"Core renderer not found at {renderer}") + raise SystemExit(f"Renderer not found at {renderer}") # Every element of argv is either fixed, a path this script resolved, or a # validated plugin id -- never a shell string. cmd: List[str] = [ sys.executable, str(renderer), + *extra, "--plugin", validate_plugin_id(plugin_id), "--plugin-dir", str(PLUGINS_DIR), "--config", json.dumps(config),