From 7010233a4b14edada9ac3b980bd88e51ec6620be Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:59:36 -0400 Subject: [PATCH] fix(odds-ticker): stop the glibc-only date format crashing the ticker _create_game_display formatted the kick-off date with strftime("%-m/%d"). %-m is a glibc extension: on Windows and on musl it raises ValueError: Invalid format string. The exception escaped the per-game render, so the whole ticker fell back to "No odds data" rather than one card failing. It now builds the same "9/13" text portably. Identical output on glibc, and the plugin can be rendered off a Pi. Ten other plugins call %-m, %-d or %-I -- mostly inside the copied sports.py lineage, so a sweep there belongs in its own PR: filed as #414. Bitmap fonts: a .bdf exists at exactly one pixel size, and the loader caught FreeType's refusal and silently substituted PressStart2P. It now retries at the face's declared PIXEL_SIZE. cozette.bdf is dropped from the three font menus because the core ships no file of that name (#387). Also removes a duplicate import pytz that pyflakes flagged. README: the configuration section documented 8 settings as top-level keys -- display_duration, scroll_speed, scroll_delay, show_favorite_teams_only, games_per_favorite_team, max_games_per_league, show_odds_only, future_fetch_days. Every one of them is nested under display_options, filtering or data_settings, and the schema sets additionalProperties: false, so a config written the way the README described is rejected rather than ignored. Two of the eight also gave the wrong default: scroll_speed is 1.0 not 2, and scroll_delay is 0.02 not 0.05. All 47 settings are now documented at their real paths, including the four broadcast-logo and duration settings and the per-league blocks. Nothing in this plugin is dead -- every setting is read by the plugin or the core. check_plugin.py: 8/8 pass. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/assets/odds-ticker/channel-logos.png | Bin 0 -> 17492 bytes docs/assets/odds-ticker/hero.png | Bin 0 -> 4548 bytes docs/assets/odds-ticker/panel-sizes.png | Bin 0 -> 15601 bytes docs/assets/odds-ticker/shots.json | 180 ++++++++++++++++++++++ plugins.json | 4 +- plugins/odds-ticker/README.md | 169 +++++++++++--------- plugins/odds-ticker/config_schema.json | 9 +- plugins/odds-ticker/manager.py | 4 +- plugins/odds-ticker/manifest.json | 12 +- plugins/odds-ticker/odds_renderer.py | 34 +++- 11 files changed, 328 insertions(+), 86 deletions(-) create mode 100644 docs/assets/odds-ticker/channel-logos.png create mode 100644 docs/assets/odds-ticker/hero.png create mode 100644 docs/assets/odds-ticker/panel-sizes.png create mode 100644 docs/assets/odds-ticker/shots.json diff --git a/README.md b/README.md index 812a069d..290ad90c 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,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 | | | [March Madness](./plugins/march-madness/) | NCAA tournament bracket tracker with round branding and live scores | | | [NFL Draft](./plugins/nfl-draft/) | Projected & live NFL draft picks from ESPN | | -| [Odds Ticker](./plugins/odds-ticker/) | Betting odds & lines across NFL, NBA, MLB, NHL, NCAA | | +| [Odds Ticker](./plugins/odds-ticker/) | Betting odds & lines across NFL, NBA, MLB, NHL, NCAA | odds-ticker on an LED panel | | [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 0000000000000000000000000000000000000000..ca389a7db452535405ae068abd45c1584fbb3142 GIT binary patch literal 17492 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 Zy6Di`cU=AFU?uAyEBj-W|GaSHe*lWA8w&sc literal 0 HcmV?d00001 diff --git a/docs/assets/odds-ticker/hero.png b/docs/assets/odds-ticker/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..24a43fb7aaf7f8cfac3ae30c1893602c11bf89fa GIT binary patch literal 4548 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 gb>9cR^7@9EM5@)au}jTT>6*sVxJ?)J@ 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 Tgw(v7uQc+co>tax7ytY}UK>1- literal 0 HcmV?d00001 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 f818927f..ce2efae7 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 +![A game card on a 256x32 panel: both team logos, the abbreviations and records, +the spread and over/under, and the kick-off day and time](../../docs/assets/odds-ticker/hero.png) + +*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 + +![show_channel_logos on and off](../../docs/assets/odds-ticker/channel-logos.png) + +![The same card on four panel sizes](../../docs/assets/odds-ticker/panel-sizes.png) ## 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"