diff --git a/README.md b/README.md
index 7d9f50a4..5ad3ca64 100644
--- a/README.md
+++ b/README.md
@@ -94,10 +94,10 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
| [NRL Scoreboard](./plugins/nrl-scoreboard/) | National Rugby League live, recent & upcoming games | |
| [F1 Scoreboard](./plugins/f1-scoreboard/) | Formula 1 race results, schedules, and standings | |
| [UFC Scoreboard](./plugins/ufc-scoreboard/) | UFC/MMA live fights, fighter headshots, records, odds & results — *by [LegoGuy1000](https://github.com/legoguy1000)* | |
+| [Odds Ticker](./plugins/odds-ticker/) | Betting odds & lines across NFL, NBA, MLB, NHL, NCAA |
|
| [Masters Tournament](./plugins/masters-tournament/) | Live Masters golf leaderboard, hole tracking, player cards |
|
| [NFL Draft](./plugins/nfl-draft/) | Projected & live NFL draft picks from ESPN |
|
| [March Madness](./plugins/march-madness/) | NCAA tournament bracket tracker with round branding and live scores |
|
-| [Odds Ticker](./plugins/odds-ticker/) | Betting odds & lines across NFL, NBA, MLB, NHL, NCAA | |
| [Sports Leaderboard](./plugins/ledmatrix-leaderboard/) | League standings, rankings, conference records | |
| [Olympics Countdown](./plugins/olympics/) | Countdown to next Olympics with live medal counts |
|
diff --git a/docs/assets/odds-ticker/channel-logos.png b/docs/assets/odds-ticker/channel-logos.png
new file mode 100644
index 00000000..ca389a7d
Binary files /dev/null and b/docs/assets/odds-ticker/channel-logos.png differ
diff --git a/docs/assets/odds-ticker/hero.png b/docs/assets/odds-ticker/hero.png
new file mode 100644
index 00000000..24a43fb7
Binary files /dev/null and b/docs/assets/odds-ticker/hero.png differ
diff --git a/docs/assets/odds-ticker/panel-sizes.png b/docs/assets/odds-ticker/panel-sizes.png
new file mode 100644
index 00000000..1f62b25f
Binary files /dev/null and b/docs/assets/odds-ticker/panel-sizes.png differ
diff --git a/docs/assets/odds-ticker/shots.json b/docs/assets/odds-ticker/shots.json
new file mode 100644
index 00000000..43c4873f
--- /dev/null
+++ b/docs/assets/odds-ticker/shots.json
@@ -0,0 +1,180 @@
+{
+ "plugin": "odds-ticker",
+ "_comment": "Games are seeded onto the plugin instance rather than fetched from ESPN, and last_update is seeded too or display() would re-fetch and clear them. The ticker scrolls, so shots step frames. Team records are looked up live per team during update(), which these renders skip, so they show as (N/A). Matchups and lines are invented.",
+ "defaults": {
+ "width": 128,
+ "height": 32,
+ "scale": 6,
+ "freeze_time": "2026-09-12T18:00:00+00:00",
+ "skip_update": true,
+ "frames": 60,
+ "config": {
+ "enabled": true
+ },
+ "attrs": {
+ "games_data": [
+ {
+ "league": "nfl",
+ "away_team": "KC",
+ "away_id": "12",
+ "home_team": "BUF",
+ "home_id": "2",
+ "start_time": "2026-09-13T20:25:00Z",
+ "broadcast": "CBS",
+ "logo_dir": "assets/sports/nfl_logos",
+ "odds": {
+ "away_team_odds": {
+ "money_line": "+118"
+ },
+ "home_team_odds": {
+ "money_line": "-140"
+ },
+ "spread": -2.5,
+ "over_under": 48.5
+ }
+ },
+ {
+ "league": "nfl",
+ "away_team": "DAL",
+ "away_id": "6",
+ "home_team": "PHI",
+ "home_id": "21",
+ "start_time": "2026-09-14T00:20:00Z",
+ "broadcast": "NBC",
+ "logo_dir": "assets/sports/nfl_logos",
+ "odds": {
+ "away_team_odds": {
+ "money_line": "+165"
+ },
+ "home_team_odds": {
+ "money_line": "-195"
+ },
+ "spread": -4.0,
+ "over_under": 45.5
+ }
+ },
+ {
+ "league": "nba",
+ "away_team": "BOS",
+ "away_id": "2",
+ "home_team": "LAL",
+ "home_id": "13",
+ "start_time": "2026-09-14T02:30:00Z",
+ "broadcast": "ESPN",
+ "logo_dir": "assets/sports/nba_logos",
+ "odds": {
+ "away_team_odds": {
+ "money_line": "-105"
+ },
+ "home_team_odds": {
+ "money_line": "-115"
+ },
+ "spread": 1.5,
+ "over_under": 224.5
+ }
+ }
+ ],
+ "last_update": 1789236000.0,
+ "is_enabled": true
+ }
+ },
+ "shots": [
+ {
+ "name": "hero",
+ "frames": 160,
+ "width": 256
+ },
+ {
+ "name": "card",
+ "frames": 160,
+ "width": 256,
+ "standalone": false
+ },
+ {
+ "name": "logos-on",
+ "frames": 160,
+ "width": 256,
+ "standalone": false,
+ "config": {
+ "display_options": {
+ "show_channel_logos": true
+ }
+ }
+ },
+ {
+ "name": "logos-off",
+ "frames": 160,
+ "width": 256,
+ "standalone": false,
+ "config": {
+ "display_options": {
+ "show_channel_logos": false
+ }
+ }
+ },
+ {
+ "name": "size-64x32",
+ "width": 64,
+ "frames": 160,
+ "standalone": false
+ },
+ {
+ "name": "size-128x32",
+ "width": 128,
+ "frames": 160,
+ "standalone": false
+ },
+ {
+ "name": "size-128x64",
+ "width": 128,
+ "height": 64,
+ "frames": 160,
+ "standalone": false
+ },
+ {
+ "name": "size-256x32",
+ "width": 256,
+ "frames": 160,
+ "standalone": false
+ }
+ ],
+ "composites": [
+ {
+ "name": "channel-logos",
+ "columns": 1,
+ "cells": [
+ {
+ "shot": "logos-on",
+ "label": "show_channel_logos: true",
+ "sublabel": "the broadcaster's mark, where one is available"
+ },
+ {
+ "shot": "logos-off",
+ "label": "show_channel_logos: false"
+ }
+ ]
+ },
+ {
+ "name": "panel-sizes",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "size-64x32",
+ "label": "64 x 32"
+ },
+ {
+ "shot": "size-128x32",
+ "label": "128 x 32"
+ },
+ {
+ "shot": "size-128x64",
+ "label": "128 x 64"
+ },
+ {
+ "shot": "size-256x32",
+ "label": "256 x 32"
+ }
+ ]
+ }
+ ]
+}
diff --git a/plugins.json b/plugins.json
index 4d24b03c..77fb824a 100644
--- a/plugins.json
+++ b/plugins.json
@@ -630,10 +630,10 @@
"plugin_path": "plugins/odds-ticker",
"stars": 0,
"downloads": 0,
- "last_updated": "2026-08-05",
+ "last_updated": "2026-09-03",
"verified": true,
"screenshot": "",
- "latest_version": "1.3.3"
+ "latest_version": "1.4.0"
},
{
"id": "of-the-day",
diff --git a/plugins/odds-ticker/README.md b/plugins/odds-ticker/README.md
index 5c3b548e..f5e0da5b 100644
--- a/plugins/odds-ticker/README.md
+++ b/plugins/odds-ticker/README.md
@@ -13,6 +13,14 @@
# Odds Ticker Plugin
+
+
+*Every image in this README is real plugin output, rendered at the true panel
+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.
## Features
@@ -27,83 +35,104 @@ A plugin for LEDMatrix that displays scrolling odds and betting lines for upcomi
## Configuration
-### Global Settings
-
-- `display_duration`: How long to show the ticker (10-300 seconds, default: 30)
-- `scroll_speed`: Scrolling speed multiplier (0.5-10, default: 2)
-- `scroll_delay`: Delay between scroll steps (0.01-0.5 seconds, default: 0.05)
-- `show_favorite_teams_only`: Only show odds for favorite teams (default: false)
-- `games_per_favorite_team`: Number of games per favorite team (1-5, default: 1)
-- `max_games_per_league`: Maximum games per league (1-20, default: 5)
-- `show_odds_only`: Show only odds, no game details (default: false)
-- `future_fetch_days`: Days ahead to fetch games (1-14, default: 7)
-
-### Per-League Settings
-
-#### NFL Configuration
-
-```json
-{
- "leagues": {
- "nfl": {
- "enabled": true,
- "favorite_teams": ["TB", "DAL", "GB"]
- }
- }
-}
-```
-
-#### NBA Configuration
-
-```json
-{
- "leagues": {
- "nba": {
- "enabled": true,
- "favorite_teams": ["LAL", "BOS", "GS"]
- }
- }
-}
-```
-
-#### MLB Configuration
-
-```json
-{
- "leagues": {
- "mlb": {
- "enabled": true,
- "favorite_teams": ["NYY", "BOS", "LAD"]
- }
- }
-}
-```
-
-#### NCAA Football Configuration
+Every setting except `enabled` lives inside one of five blocks —
+`display_options`, `data_settings`, `filtering`, `leagues` or `customization`.
+The schema sets `additionalProperties: false`, so a key written at the top
+level is **rejected**, not ignored. The full schema is
+[`config_schema.json`](config_schema.json).
```json
{
- "leagues": {
- "ncaa_fb": {
- "enabled": true,
- "favorite_teams": ["UGA", "AUB", "BAMA"]
- }
+ "odds-ticker": {
+ "enabled": true,
+ "display_options": { "scroll_speed": 1.0 },
+ "filtering": { "max_games_per_league": 5 },
+ "leagues": { "nfl": { "enabled": true, "favorite_teams": ["KC", "BUF"] } }
}
}
```
-#### NCAA Basketball Configuration
-
-```json
-{
- "leagues": {
- "ncaam_basketball": {
- "enabled": true,
- "favorite_teams": ["DUKE", "UNC", "KANSAS"]
- }
- }
-}
-```
+### Display
+
+| Key | Default | Notes |
+|---|---|---|
+| `display_options.display_duration` | `30` | Duration in seconds to display the odds ticker (used when dynamic_duration is disabled) (10–300). |
+| `display_options.dynamic_duration` | `true` | Enable dynamic duration based on content width. Automatically adjusts display time based on how much content there is. |
+| `display_options.min_duration` | `30` | Minimum display duration in seconds when dynamic duration is enabled (10–300). |
+| `display_options.max_duration` | `300` | Maximum display duration in seconds when dynamic duration is enabled (30–600). |
+| `display_options.duration_buffer` | `0.1` | Extra buffer time added to calculated duration (as percentage, 0.1 = 10%) (0.01–1.0). |
+| `display_options.scroll_speed` | `1.0` | Scrolling speed in pixels per frame (0.5–5.0). |
+| `display_options.scroll_delay` | `0.02` | Delay between scroll steps in seconds (lower = faster scrolling) (0.001–0.1). |
+| `display_options.scroll_pixels_per_second` | `50.0` | Scroll speed in pixels per second (used for dynamic duration calculation). Set to match your actual scroll rate: scroll_speed / scroll_delay (5.0–100.0). |
+| `display_options.target_fps` | `120` | Target frames per second for smooth scrolling. Higher values = smoother animation (120 recommended) (30–200). |
+| `display_options.loop` | `true` | Continuously loop the ticker. If false, stops at the end. |
+| `display_options.show_channel_logos` | `true` | Show broadcast channel logos. |
+| `display_options.broadcast_logo_height_ratio` | `0.8` | Height ratio for broadcast channel logos (0.8 = 80% of display height) (0.1–1.0). |
+| `display_options.broadcast_logo_max_width_ratio` | `0.8` | Maximum width ratio for broadcast channel logos relative to display width (0.1–2.0). |
+
+### Data fetching
+
+| Key | Default | Notes |
+|---|---|---|
+| `data_settings.update_interval` | `3600` | How often to fetch new odds data in seconds when there are no live games (300–86400). |
+| `data_settings.live_game_update_interval` | `60` | How often to fetch new odds data in seconds when there are live games being displayed (30–300). |
+| `data_settings.future_fetch_days` | `7` | Days ahead to fetch upcoming games (1–90). |
+| `data_settings.request_timeout` | `30` | Request timeout in seconds for API calls (5–120). |
+| `data_settings.fetch_odds` | `true` | Enable fetching of betting odds. |
+
+### Which games appear
+
+| Key | Default | Notes |
+|---|---|---|
+| `filtering.show_favorite_teams_only` | `false` | Only show odds for favorite teams across all leagues. |
+| `filtering.games_per_favorite_team` | `1` | Number of games to show per favorite team (1–5). |
+| `filtering.max_games_per_league` | `5` | Maximum number of games to show per league (1–20). |
+| `filtering.show_odds_only` | `false` | Include only games that have odds data; games without odds will be excluded from the ticker. |
+| `filtering.sort_order` | `"soonest"` | Sort order for displaying games — one of `soonest`, `league`, `team`. |
+
+### Fonts
+
+| Key | Default | Notes |
+|---|---|---|
+| `customization.team_text.font` | `"PressStart2P-Regular.ttf"` | Select the font to use — one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`, `5x7.bdf`, `4x6.bdf`. |
+| `customization.team_text.font_size` | `8` | Font size in pixels (4–16). |
+| `customization.odds_text.font` | `"PressStart2P-Regular.ttf"` | Select the font to use — one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`, `5x7.bdf`, `4x6.bdf`. |
+| `customization.odds_text.font_size` | `8` | Font size in pixels (4–16). |
+| `customization.datetime_text.font` | `"PressStart2P-Regular.ttf"` | Select the font to use — one of `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`, `5x7.bdf`, `4x6.bdf`. |
+| `customization.datetime_text.font_size` | `8` | Font size in pixels (4–16). |
+
+### Leagues
+
+Each league takes `enabled` and `favorite_teams`, and `ncaam_basketball` adds
+`show_seeds_in_tournament`. `favorite_teams` defaults to `null`, which means
+"no favourites for this league" rather than an empty list.
+
+| Key | Default |
+|---|---|
+| `leagues.nfl.enabled` | `true` |
+| `leagues.nfl.favorite_teams` | `null` |
+| `leagues.nba.enabled` | `true` |
+| `leagues.nba.favorite_teams` | `null` |
+| `leagues.mlb.enabled` | `true` |
+| `leagues.mlb.favorite_teams` | `null` |
+| `leagues.nhl.enabled` | `true` |
+| `leagues.nhl.favorite_teams` | `null` |
+| `leagues.milb.enabled` | `false` |
+| `leagues.milb.favorite_teams` | `null` |
+| `leagues.ncaa_fb.enabled` | `false` |
+| `leagues.ncaa_fb.favorite_teams` | `null` |
+| `leagues.ncaam_basketball.enabled` | `false` |
+| `leagues.ncaam_basketball.favorite_teams` | `null` |
+| `leagues.ncaam_basketball.show_seeds_in_tournament` | `true` |
+| `leagues.ncaa_baseball.enabled` | `false` |
+| `leagues.ncaa_baseball.favorite_teams` | `null` |
+
+
+### What the toggles look like
+
+
+
+
## Display Format
diff --git a/plugins/odds-ticker/config_schema.json b/plugins/odds-ticker/config_schema.json
index 5bfb8f1e..a1f8f952 100644
--- a/plugins/odds-ticker/config_schema.json
+++ b/plugins/odds-ticker/config_schema.json
@@ -596,8 +596,7 @@
"4x6-font.ttf",
"5by7.regular.ttf",
"5x7.bdf",
- "4x6.bdf",
- "cozette.bdf"
+ "4x6.bdf"
],
"default": "PressStart2P-Regular.ttf"
},
@@ -629,8 +628,7 @@
"4x6-font.ttf",
"5by7.regular.ttf",
"5x7.bdf",
- "4x6.bdf",
- "cozette.bdf"
+ "4x6.bdf"
],
"default": "PressStart2P-Regular.ttf"
},
@@ -662,8 +660,7 @@
"4x6-font.ttf",
"5by7.regular.ttf",
"5x7.bdf",
- "4x6.bdf",
- "cozette.bdf"
+ "4x6.bdf"
],
"default": "PressStart2P-Regular.ttf"
},
diff --git a/plugins/odds-ticker/manager.py b/plugins/odds-ticker/manager.py
index 9c68f33e..4ded99c4 100644
--- a/plugins/odds-ticker/manager.py
+++ b/plugins/odds-ticker/manager.py
@@ -1973,7 +1973,9 @@ def _create_game_display(self, game: Dict[str, Any]) -> Image.Image:
if local_time:
# Capitalize full day name, e.g., 'Tuesday'
day_text = local_time.strftime("%A")
- date_text = local_time.strftime("%-m/%d")
+ # %-m is a glibc extension: it raises ValueError on Windows
+ # and musl. Build the same "9/13" text portably instead.
+ date_text = f"{local_time.month}/{local_time.strftime('%d')}"
time_text = local_time.strftime("%I:%M%p").lstrip('0')
else:
# Fallback if time parsing failed
diff --git a/plugins/odds-ticker/manifest.json b/plugins/odds-ticker/manifest.json
index b6621c68..1adc2b2e 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.3.3",
+ "version": "1.4.0",
"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.4.0",
+ "released": "2026-09-03",
+ "notes": "Portability fix and documentation. Two strftime calls used %-m, a glibc extension that raises ValueError on Windows and musl; the exception escaped _create_game_display and the whole ticker fell back to 'No odds data'. They now build the same text portably (#414 tracks the same pattern in ten other plugins). Bitmap fonts now load at their declared PIXEL_SIZE instead of silently swapping in another face, and cozette.bdf is dropped from the three font menus because the core ships no such file (#387). The README documented 8 settings at top level when the schema nests every one of them under display_options, data_settings or filtering -- and because additionalProperties is false, a config using those paths is rejected rather than ignored. Two of the eight also gave the wrong default. All 47 settings are now documented at their real paths, with real rendered screenshots.",
+ "ledmatrix_min_version": "2.0.0"
+ },
{
"version": "1.3.3",
"released": "2026-08-31",
@@ -70,7 +76,7 @@
{
"released": "2026-07-18",
"version": "1.1.7",
- "notes": "Fix config save failing with a 400 validation error. The per-league favorite-team pickers hardcoded several wrong team abbreviations that didn't match ESPN's (Rams LA→LAR, White Sox CWS→CHW, A's OAK→ATH, Warriors GSW→GS, Pelicans NOP→NO, Knicks NYK→NY, Spurs SAS→SA, Jazz UTA→UTAH, Wizards WAS→WSH, NHL Kings LAK→LA, Capitals WAS→WSH), so valid favorites were rejected on save and never matched games. Also removed the overly strict regex on the NCAA/MiLB free-text team fields so documented values like AP_TOP_25 are accepted.",
+ "notes": "Fix config save failing with a 400 validation error. The per-league favorite-team pickers hardcoded several wrong team abbreviations that didn't match ESPN's (Rams LA\u2192LAR, White Sox CWS\u2192CHW, A's OAK\u2192ATH, Warriors GSW\u2192GS, Pelicans NOP\u2192NO, Knicks NYK\u2192NY, Spurs SAS\u2192SA, Jazz UTA\u2192UTAH, Wizards WAS\u2192WSH, NHL Kings LAK\u2192LA, Capitals WAS\u2192WSH), so valid favorites were rejected on save and never matched games. Also removed the overly strict regex on the NCAA/MiLB free-text team fields so documented values like AP_TOP_25 are accepted.",
"ledmatrix_min": "2.0.0"
},
{
@@ -123,7 +129,7 @@
],
"stars": 0,
"downloads": 0,
- "last_updated": "2026-08-05",
+ "last_updated": "2026-09-03",
"verified": true,
"screenshot": "",
"display_modes": [
diff --git a/plugins/odds-ticker/odds_renderer.py b/plugins/odds-ticker/odds_renderer.py
index be9f7c10..641d459b 100644
--- a/plugins/odds-ticker/odds_renderer.py
+++ b/plugins/odds-ticker/odds_renderer.py
@@ -20,6 +20,20 @@
from pathlib import Path
+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; no point reading the glyphs
+ except (OSError, ValueError, IndexError):
+ return None
+ return None
+
+
def _pixel_draw(image):
"""ImageDraw that renders text crisply on the LED grid.
@@ -158,7 +172,21 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any],
font = ImageFont.truetype(font_path, font_size)
logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
return font
- except Exception:
+ except OSError:
+ # A .bdf is a bitmap face that exists at exactly one
+ # pixel size; FreeType refuses every other. Retry at the
+ # size the file declares rather than silently swapping
+ # in a different font.
+ native = _bdf_pixel_size(font_path)
+ if native is not None and native != font_size:
+ try:
+ font = ImageFont.truetype(font_path, native)
+ logger.debug(
+ "Loaded bitmap font %s at its native size %d "
+ "(requested %d)", font_name, native, font_size)
+ return font
+ except OSError:
+ pass
logger.warning(f"Could not load BDF font {font_name} with PIL, using default")
else:
logger.warning(f"Unknown font file type: {font_name}, using default")
@@ -324,7 +352,6 @@ def _create_game_display(self, game: Dict) -> Image.Image:
if isinstance(game_time, str):
try:
from datetime import datetime
- import pytz
game_time = datetime.fromisoformat(game_time.replace('Z', '+00:00'))
except Exception:
game_time = None
@@ -338,7 +365,8 @@ def _create_game_display(self, game: Dict) -> Image.Image:
game_time = game_time.replace(tzinfo=pytz.UTC)
local_time = game_time.astimezone(tz)
day_text = local_time.strftime("%a") # Day of week
- date_text = local_time.strftime("%-m/%d") # Month/Day
+ # %-m is a glibc extension; see manager.py for the same fix.
+ date_text = f"{local_time.month}/{local_time.strftime('%d')}" # Month/Day
time_text = local_time.strftime("%I:%M%p").lstrip('0') # Time
except Exception:
day_text = "TBD"