diff --git a/plugins.json b/plugins.json index 977a3051..3f6b8dfd 100644 --- a/plugins.json +++ b/plugins.json @@ -50,7 +50,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.0.5" + "latest_version": "1.0.6" }, { "id": "baseball-scoreboard", @@ -76,7 +76,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.40.3" + "latest_version": "1.40.4" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.29.4" + "latest_version": "1.29.5" }, { "id": "calendar", @@ -124,7 +124,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.5" + "latest_version": "1.2.6" }, { "id": "christmas-countdown", @@ -146,7 +146,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.0.3" + "latest_version": "1.0.4" }, { "id": "clock-simple", @@ -240,7 +240,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.5.1" + "latest_version": "3.5.2" }, { "id": "geochron", @@ -266,7 +266,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.0.4", + "latest_version": "1.0.5", "icon": "fa-globe" }, { @@ -311,7 +311,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.1.1" }, { "id": "hockey-scoreboard", @@ -335,7 +335,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.25.3", + "latest_version": "1.25.4", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.24.4", + "latest_version": "1.24.5", "icon": "fas fa-baseball-ball" }, { @@ -412,7 +412,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.3.6" + "latest_version": "1.3.7" }, { "id": "ledmatrix-flights", @@ -533,7 +533,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.4.0" + "latest_version": "1.4.1" }, { "id": "news", @@ -581,7 +581,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.7", + "latest_version": "1.2.8", "icon": "fa-circle-dot" }, { @@ -605,7 +605,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.4.6", + "latest_version": "1.4.7", "icon": "fas fa-football-ball" }, { @@ -681,7 +681,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "2.0.3" + "latest_version": "2.0.4" }, { "id": "pga-tour-leaderboard", @@ -760,7 +760,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.24.4" + "latest_version": "2.24.5" }, { "id": "static-image", @@ -783,7 +783,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.0.7" + "latest_version": "1.0.8" }, { "id": "stock-news", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.22.3", + "latest_version": "1.22.4", "last_updated": "2026-09-02" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.21.3" + "latest_version": "1.21.4" }, { "id": "jellyfin-now-playing", diff --git a/plugins/7-segment-clock/manager.py b/plugins/7-segment-clock/manager.py index 83a77113..7dcc703f 100644 --- a/plugins/7-segment-clock/manager.py +++ b/plugins/7-segment-clock/manager.py @@ -290,11 +290,21 @@ def _calculate_scale_factor( """ # Calculate base width needed for the time string base_width = 0 + element_count = 0 for item in digits: if item == ":": base_width += self.separator_width + element_count += 1 elif item is not None: base_width += self.digit_width + element_count += 1 + + # The gaps scale with the digits, so they belong in the width the scale + # is solved against. Sizing the digits to the panel and then adding the + # spacing on top is what pushed the clock off the right edge for any + # digit_spacing >= 3, a range the schema advertises up to 10. + if element_count > 1: + base_width += (element_count - 1) * self.digit_spacing base_height = self.digit_height diff --git a/plugins/7-segment-clock/manifest.json b/plugins/7-segment-clock/manifest.json index 6eeb7c08..a6171216 100644 --- a/plugins/7-segment-clock/manifest.json +++ b/plugins/7-segment-clock/manifest.json @@ -1,7 +1,7 @@ { "id": "7-segment-clock", "name": "7-Segment Clock", - "version": "1.0.5", + "version": "1.0.6", "description": "Display a retro-style 7-segment clock with customizable colors", "author": "LEDMatrix", "entry_point": "manager.py", @@ -24,6 +24,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.0.6", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-02", "version": "1.0.5", diff --git a/plugins/7-segment-clock/test/golden/128x96/7-segment-clock.png b/plugins/7-segment-clock/test/golden/128x96/7-segment-clock.png index 93e529ff..6c6ab7eb 100644 Binary files a/plugins/7-segment-clock/test/golden/128x96/7-segment-clock.png and b/plugins/7-segment-clock/test/golden/128x96/7-segment-clock.png differ diff --git a/plugins/7-segment-clock/test/golden/64x64/7-segment-clock.png b/plugins/7-segment-clock/test/golden/64x64/7-segment-clock.png index 41d4adb8..56d55635 100644 Binary files a/plugins/7-segment-clock/test/golden/64x64/7-segment-clock.png and b/plugins/7-segment-clock/test/golden/64x64/7-segment-clock.png differ diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index f96376b1..509a582e 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -776,8 +776,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true @@ -832,8 +831,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true @@ -888,8 +886,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true @@ -944,8 +941,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf", "x-advanced": true @@ -1000,8 +996,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf", "x-advanced": true @@ -1095,8 +1090,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index b81d91d9..56103e87 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.22.3", + "version": "1.22.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.22.4", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "1.22.3", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index f10aa832..fcdfe242 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -96,6 +96,20 @@ def resolve_font_name(font_name: str) -> str: _DERIVE_TOP_SPAN = object() + +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 + class GameRenderer(SportsGameRendererMixin): """Renders individual baseball game cards as PIL Images.""" @@ -226,18 +240,25 @@ def _load_custom_font(self, element_config: Dict[str, Any], default_size: int = if font_path.lower().endswith('.ttf') or font_path.lower().endswith('.otf'): return ImageFont.truetype(font_path, font_size) elif font_path.lower().endswith('.bdf'): - # BDF fonts require pre-conversion: pilfont.py font.bdf -> font.pil + font.pbm - pil_font_path = font_path.rsplit('.', 1)[0] + '.pil' - if os.path.exists(pil_font_path): - try: - return ImageFont.load(pil_font_path) - except Exception as e: - self.logger.warning(f"Failed to load pre-converted BDF font {pil_font_path}: {e}") - else: + # FreeType reads BDF directly -- no pre-conversion needed. + # This used to look for a .pil/.pbm pair produced by + # pilfont.py, and no .pil ships anywhere, so every .bdf in + # the picker warned and fell back to the default font. + try: + return ImageFont.truetype(font_path, font_size) + except OSError: + # A bitmap face exists at exactly the size it was drawn + # at; FreeType rejects any other with "invalid pixel + # size". Retry at the size the file declares. + native = _bdf_pixel_size(font_path) + if native is not None and native != font_size: + try: + return ImageFont.truetype(font_path, native) + except OSError: + pass self.logger.warning( - f"BDF font {font_name} requires conversion. " - f"Run: pilfont.py {font_path}" - ) + f"Could not load BDF font {font_name} at {font_size} " + f"or its native size") else: self.logger.warning(f"Unknown font file type: {font_name}") else: diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c9f55f7d..c4fcc22c 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.40.3", + "version": "1.40.4", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.40.4", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "1.40.3", @@ -412,7 +418,7 @@ { "released": "2026-07-08", "version": "1.14.2", - "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team \u25b2/\u25bc indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", + "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team ▲/▼ indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", "ledmatrix_min": "2.0.0" }, { @@ -430,7 +436,7 @@ { "released": "2026-07-07", "version": "1.13.1", - "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team \u25b2/\u25bc arrow, which wasn't factored into the width check at all.", + "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team ▲/▼ arrow, which wasn't factored into the width check at all.", "ledmatrix_min": "2.0.0" }, { @@ -472,7 +478,7 @@ { "released": "2026-07-02", "version": "1.7.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/baseball-scoreboard/test_pitcher_batter_last_play.py b/plugins/baseball-scoreboard/test_pitcher_batter_last_play.py index 31b9dc1e..0502e066 100644 --- a/plugins/baseball-scoreboard/test_pitcher_batter_last_play.py +++ b/plugins/baseball-scoreboard/test_pitcher_batter_last_play.py @@ -242,6 +242,13 @@ def _make_render_live(width, height): live.config = {} live.show_pitcher_batter = True live.show_last_play = True + # SportsCore.__init__ creates these; object.__new__ skips it, and the + # at-bat card's font ladder reads _font_cache on its first draw. Without + # them every render here raised AttributeError, was swallowed by the + # plugin's except, and drew nothing -- so four checks below failed on an + # empty canvas rather than on anything the renderer did wrong. + live._font_cache = {} + live._bdf_native_size_cache = {} import logging live.logger = logging.getLogger("test_at_bat_info_render") return live diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index d9736217..12ff7271 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -2313,8 +2313,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -2369,8 +2368,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -2425,8 +2423,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -2481,8 +2478,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -2537,8 +2533,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -2632,8 +2627,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 0d0ee2a3..221f9d07 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.29.4", + "version": "1.29.5", "update_interval": 60, "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", @@ -19,6 +19,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.29.5", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "1.29.4", @@ -347,7 +353,7 @@ { "released": "2026-07-02", "version": "1.6.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/calendar/config_schema.json b/plugins/calendar/config_schema.json index ddab8df0..e722c67a 100644 --- a/plugins/calendar/config_schema.json +++ b/plugins/calendar/config_schema.json @@ -105,8 +105,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true @@ -141,8 +140,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf", "x-advanced": true diff --git a/plugins/calendar/manager.py b/plugins/calendar/manager.py index 8ce3e77a..5d17dbe4 100644 --- a/plugins/calendar/manager.py +++ b/plugins/calendar/manager.py @@ -231,30 +231,48 @@ def _load_font_by_type(self, font_path: str, font_size: int, self.logger.info(f"Using default font for {font_name}") return ImageFont.load_default() - # Get file extension to determine loader type - _, ext = os.path.splitext(font_path.lower()) - - # Try BDF bitmap font loader - if ext == '.bdf': - try: - font = ImageFont.load(font_path) - self.logger.debug(f"Loaded BDF font for {font_name}: {font_path}") - return font - except Exception as e: - self.logger.warning(f"Failed to load BDF font for {font_name}: {e}") - self.logger.info(f"Using default font for {font_name}") - return ImageFont.load_default() - - # Try TrueType font loader (for .ttf, .otf, and other formats) + # FreeType handles .ttf and (at its native size) .bdf faces, so one + # loader covers both. ImageFont.load() was used for .bdf, but that is + # Pillow's legacy PIL-bitmap reader: it wants a .pil metrics file plus a + # .pbm/.gif/.png glyph image and has never read BDF, so it failed with + # "cannot find glyph data file" and every .bdf in the picker silently + # fell back to the default font. try: font = ImageFont.truetype(font_path, font_size) - self.logger.debug(f"Loaded TrueType font for {font_name}: {font_path}") + self.logger.debug(f"Loaded font for {font_name}: {font_path}") return font - except Exception as e: - self.logger.warning(f"Failed to load TrueType font for {font_name}: {e}") + except OSError as e: + # A .bdf is a bitmap face and exists at exactly the one pixel size + # it was drawn at; FreeType rejects any other with "invalid pixel + # size". Retry at the size the file declares. + native = self._bdf_pixel_size(font_path) + if native is not None and native != font_size: + try: + font = ImageFont.truetype(font_path, native) + self.logger.debug( + "Loaded bitmap font for %s at its native size %d (requested %d)", + font_name, native, font_size) + return font + except OSError: + pass + self.logger.warning(f"Failed to load font for {font_name}: {e}") self.logger.info(f"Using default font for {font_name}") return ImageFont.load_default() + @staticmethod + def _bdf_pixel_size(path: str): + """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 _get_display_dimensions(self, width: Optional[int] = None, height: Optional[int] = None) -> tuple: """ Get display dimensions dynamically. diff --git a/plugins/calendar/manifest.json b/plugins/calendar/manifest.json index eab688a3..7263e979 100644 --- a/plugins/calendar/manifest.json +++ b/plugins/calendar/manifest.json @@ -1,7 +1,7 @@ { "id": "calendar", "name": "Google Calendar", - "version": "1.2.5", + "version": "1.2.6", "author": "ChuckBuilds", "description": "Display upcoming events from Google Calendar with date, time, and event details. Shows next 1-3 events with automatic rotation and timezone support.", "category": "productivity", @@ -35,6 +35,12 @@ } ], "versions": [ + { + "version": "1.2.6", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-03", "version": "1.2.5", diff --git a/plugins/christmas-countdown/manager.py b/plugins/christmas-countdown/manager.py index e2e7a650..3b757721 100644 --- a/plugins/christmas-countdown/manager.py +++ b/plugins/christmas-countdown/manager.py @@ -487,8 +487,12 @@ def display(self, force_clear: bool = False) -> None: # Stack text on right side # Split message into words/lines for stacking if self.is_christmas or self.days_until_christmas == 0: - # "MERRY CHRISTMAS" - split into two lines - lines = ["MERRY", "CHRISTMAS"] + # "MERRY CHRISTMAS" - split into two lines. Honour the same + # use_xmas fit-check the countdown lines use: "CHRISTMAS" does + # not fit the right half of a 64px panel, and hardcoding it + # here ran the word 3px off the edge for the whole of Dec 25 -- + # the one day this branch is ever reached. + lines = ["MERRY", "XMAS" if use_xmas else "CHRISTMAS"] else: # Countdown message - split intelligently if use_xmas: diff --git a/plugins/christmas-countdown/manifest.json b/plugins/christmas-countdown/manifest.json index 0e4e6d4f..22914afb 100644 --- a/plugins/christmas-countdown/manifest.json +++ b/plugins/christmas-countdown/manifest.json @@ -1,7 +1,7 @@ { "id": "christmas-countdown", "name": "Christmas Countdown", - "version": "1.0.3", + "version": "1.0.4", "author": "ChuckBuilds", "description": "Display a countdown to Christmas with a stylized Christmas tree logo and festive text", "category": "holiday", @@ -17,6 +17,12 @@ "christmas-countdown" ], "versions": [ + { + "version": "1.0.4", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-02", "version": "1.0.3", diff --git a/plugins/christmas-countdown/test/golden/128x32/christmas-countdown.png b/plugins/christmas-countdown/test/golden/128x32/christmas-countdown.png index b2fbefd0..f1653ee7 100644 Binary files a/plugins/christmas-countdown/test/golden/128x32/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/128x32/christmas-countdown.png differ diff --git a/plugins/christmas-countdown/test/golden/128x64/christmas-countdown.png b/plugins/christmas-countdown/test/golden/128x64/christmas-countdown.png index ee199ebe..dfedaf06 100644 Binary files a/plugins/christmas-countdown/test/golden/128x64/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/128x64/christmas-countdown.png differ diff --git a/plugins/christmas-countdown/test/golden/128x96/christmas-countdown.png b/plugins/christmas-countdown/test/golden/128x96/christmas-countdown.png index 94d679af..aa0691d5 100644 Binary files a/plugins/christmas-countdown/test/golden/128x96/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/128x96/christmas-countdown.png differ diff --git a/plugins/christmas-countdown/test/golden/256x32/christmas-countdown.png b/plugins/christmas-countdown/test/golden/256x32/christmas-countdown.png index 4931cfbf..eba855ef 100644 Binary files a/plugins/christmas-countdown/test/golden/256x32/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/256x32/christmas-countdown.png differ diff --git a/plugins/christmas-countdown/test/golden/64x32/christmas-countdown.png b/plugins/christmas-countdown/test/golden/64x32/christmas-countdown.png index a3d4d442..90c6fafa 100644 Binary files a/plugins/christmas-countdown/test/golden/64x32/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/64x32/christmas-countdown.png differ diff --git a/plugins/christmas-countdown/test/golden/64x64/christmas-countdown.png b/plugins/christmas-countdown/test/golden/64x64/christmas-countdown.png index 3de672e8..84992bc5 100644 Binary files a/plugins/christmas-countdown/test/golden/64x64/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/64x64/christmas-countdown.png differ diff --git a/plugins/christmas-countdown/test/golden/96x48/christmas-countdown.png b/plugins/christmas-countdown/test/golden/96x48/christmas-countdown.png index 11892774..92088691 100644 Binary files a/plugins/christmas-countdown/test/golden/96x48/christmas-countdown.png and b/plugins/christmas-countdown/test/golden/96x48/christmas-countdown.png differ diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 15d64dfb..8b2d33e2 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -1249,8 +1249,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -1305,8 +1304,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -1361,8 +1359,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -1417,8 +1414,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -1473,8 +1469,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -1568,8 +1563,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index 390090d7..70263434 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -223,6 +223,20 @@ def possession_ball_box(text_x, text_width, text_height, text_y, return (x, y, x + icon_w - 1, y + icon_h - 1) +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 + + class GameRenderer(SportsGameRendererMixin): """ Renders individual game cards as PIL Images for display. @@ -529,22 +543,24 @@ def _load_custom_font(self, element_config: Dict[str, Any], default_size: int = # TTF/OTF fonts - use ImageFont.truetype() return ImageFont.truetype(font_path, font_size) elif font_path.lower().endswith('.bdf'): - # BDF fonts - ImageFont.truetype() does NOT support BDF files - # Option (b): Try to load pre-converted .pil/.pbm file (recommended approach) - # Use pilfont.py to convert: pilfont.py font.bdf (creates font.pil and font.pbm) - pil_font_path = font_path.rsplit('.', 1)[0] + '.pil' - if os.path.exists(pil_font_path): - try: - font = ImageFont.load(pil_font_path) - self.logger.debug(f"Loaded BDF font from pre-converted PIL file: {pil_font_path}") - return font - except Exception: - # Pre-converted file exists but failed to load - will fall through to fallback - pass - - # If no pre-converted file or loading failed, BDF cannot be loaded directly - # Note: PIL.BdfFontFile doesn't exist in standard Pillow, so pre-conversion is required - # The warning will be logged only if fallback also fails (see below) + # FreeType reads BDF directly. The pre-conversion dance + # below assumed otherwise; no .pil ships anywhere, so every + # .bdf in the picker fell back to the default font. + try: + return ImageFont.truetype(font_path, font_size) + except OSError: + # A bitmap face exists at exactly the size it was drawn + # at; FreeType rejects any other with "invalid pixel + # size". Retry at the size the file declares. + native = _bdf_pixel_size(font_path) + if native is not None and native != font_size: + try: + return ImageFont.truetype(font_path, native) + except OSError: + pass + self.logger.warning( + f"Could not load BDF font {font_name} at {font_size} " + f"or its native size") else: self.logger.warning(f"Unknown font file type: {font_name}, trying fallback") else: diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 61e975e3..72773fb9 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "3.5.1", + "version": "3.5.2", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.5.2", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "3.5.1", @@ -446,7 +452,7 @@ "released": "2026-07-10", "version": "2.8.0", "ledmatrix_min_version": "2.0.0", - "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" \u2014 rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." + "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" — rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." }, { "released": "2026-07-08", diff --git a/plugins/geochron/geochron_renderer.py b/plugins/geochron/geochron_renderer.py index 83fd8914..768b4ce8 100644 --- a/plugins/geochron/geochron_renderer.py +++ b/plugins/geochron/geochron_renderer.py @@ -196,7 +196,7 @@ def format_clock(dt, fmt="24h", show_seconds=True): def build_readout(layout, dt_utc, local_dt, subsolar_lat, subsolar_lon, featured_city, - clock_format="24h", show_seconds=True, row_h=ROW_H): + clock_format="24h", show_seconds=True, row_h=ROW_H, measure=None): """Build the text rows for the digital clock / info readout. Returns a dict: @@ -207,20 +207,39 @@ def build_readout(layout, dt_utc, local_dt, subsolar_lat, subsolar_lon, featured featured_city, if given, is a dict with "name" and "local_dt" (already converted to that city's timezone by the caller). + + measure, if given, is a callable returning the pixel width of a string in + the font the caller will draw with. It is used to decide whether the clock + still fits the sidebar; without it a 4px-per-character estimate is used, + matching the pixel font this layout is designed around. """ L = layout + if measure is None: + def measure(text): + return len(text) * 4 if L["mode"] == "wide_sidebar": # The sidebar is narrow (~32px), so labels like "UTC"/"LCL" and full # ISO dates don't fit a 4px-wide pixel font. Row order conveys # meaning instead: UTC time, date, local time (if configured), # subsolar coordinates, featured city. + # 12-hour format appends AM/PM, two characters the 24-hour string does + # not carry, and with seconds that pushed the clock past the sidebar and + # off the panel. Drop the seconds rather than the meridiem when the full + # string will not fit -- the same trade the featured-city row below + # already makes for the same reason. + clock_seconds = show_seconds + if clock_seconds: + usable = L["sidebar_w"] - 2 + if measure(format_clock(dt_utc, clock_format, True)) > usable: + clock_seconds = False + rows = [ - (format_clock(dt_utc, clock_format, show_seconds), "primary"), + (format_clock(dt_utc, clock_format, clock_seconds), "primary"), (dt_utc.strftime("%m-%d"), "secondary"), ] if local_dt is not None: - rows.append((format_clock(local_dt, clock_format, show_seconds), "primary")) + rows.append((format_clock(local_dt, clock_format, clock_seconds), "primary")) rows.append((f"{subsolar_lat:+.0f},{subsolar_lon:+.0f}", "secondary")) if featured_city is not None and L["sidebar_w"] >= 50: name = featured_city["name"][:6] diff --git a/plugins/geochron/manager.py b/plugins/geochron/manager.py index 5bf4e98f..4aa6b8c9 100644 --- a/plugins/geochron/manager.py +++ b/plugins/geochron/manager.py @@ -262,6 +262,7 @@ def _draw_readout(self, draw, layout): readout = gr.build_readout( layout, now_utc, local_dt, self._subsolar_lat, self._subsolar_lon, featured_city, self.clock_format, self.show_seconds, + measure=lambda text: draw.textlength(text, font=self.font), ) primary = self.colors["text_primary_color"] diff --git a/plugins/geochron/manifest.json b/plugins/geochron/manifest.json index a7ba75df..9367d27e 100644 --- a/plugins/geochron/manifest.json +++ b/plugins/geochron/manifest.json @@ -1,7 +1,7 @@ { "id": "geochron", "name": "Geochron World Clock", - "version": "1.0.4", + "version": "1.0.5", "author": "ChuckBuilds", "description": "High-fidelity world map clock showing the real-time day/night terminator with civil/nautical/astronomical twilight bands, the subsolar point, configurable city markers, and a digital UTC/local clock - a classic Geochron, reimagined for LED matrix panels of any size.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.0.5", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "version": "1.0.4", "released": "2026-09-02", diff --git a/plugins/geochron/test/golden/128x32/geochron.png b/plugins/geochron/test/golden/128x32/geochron.png index 45da1db1..c803ea42 100644 Binary files a/plugins/geochron/test/golden/128x32/geochron.png and b/plugins/geochron/test/golden/128x32/geochron.png differ diff --git a/plugins/geochron/test/golden/128x64/geochron.png b/plugins/geochron/test/golden/128x64/geochron.png index d52c3d26..aaecf954 100644 Binary files a/plugins/geochron/test/golden/128x64/geochron.png and b/plugins/geochron/test/golden/128x64/geochron.png differ diff --git a/plugins/geochron/test/golden/128x96/geochron.png b/plugins/geochron/test/golden/128x96/geochron.png index 4219cd0e..fddf6937 100644 Binary files a/plugins/geochron/test/golden/128x96/geochron.png and b/plugins/geochron/test/golden/128x96/geochron.png differ diff --git a/plugins/geochron/test/golden/256x128/geochron.png b/plugins/geochron/test/golden/256x128/geochron.png index 107ce53e..1bf5cae3 100644 Binary files a/plugins/geochron/test/golden/256x128/geochron.png and b/plugins/geochron/test/golden/256x128/geochron.png differ diff --git a/plugins/geochron/test/golden/256x32/geochron.png b/plugins/geochron/test/golden/256x32/geochron.png index 0962c99b..8d5f489b 100644 Binary files a/plugins/geochron/test/golden/256x32/geochron.png and b/plugins/geochron/test/golden/256x32/geochron.png differ diff --git a/plugins/geochron/test/golden/64x32/geochron.png b/plugins/geochron/test/golden/64x32/geochron.png index 62a99960..bc038dc0 100644 Binary files a/plugins/geochron/test/golden/64x32/geochron.png and b/plugins/geochron/test/golden/64x32/geochron.png differ diff --git a/plugins/geochron/test/golden/64x64/geochron.png b/plugins/geochron/test/golden/64x64/geochron.png index b3146296..20fcd8e3 100644 Binary files a/plugins/geochron/test/golden/64x64/geochron.png and b/plugins/geochron/test/golden/64x64/geochron.png differ diff --git a/plugins/geochron/test/golden/96x48/geochron.png b/plugins/geochron/test/golden/96x48/geochron.png new file mode 100644 index 00000000..59e27efe Binary files /dev/null and b/plugins/geochron/test/golden/96x48/geochron.png differ diff --git a/plugins/hello-world/manager.py b/plugins/hello-world/manager.py index 2c945778..5bb5166c 100644 --- a/plugins/hello-world/manager.py +++ b/plugins/hello-world/manager.py @@ -23,7 +23,14 @@ class HelloWorldPlugin(BasePlugin): Displays a customizable greeting message with the current time. Demonstrates basic plugin functionality. """ - + + # (family, size_px) for each registered element. Kept in one place so the + # register_manager_font() call and the resolve_font() lookup cannot drift: + # resolve_font takes the family and size the element was registered with, + # and a mismatch there is silent. + MESSAGE_FONT = ("press_start", 10) + TIME_FONT = ("press_start", 8) + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): """Initialize the Hello World plugin.""" super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) @@ -58,8 +65,8 @@ def _register_fonts(self): font_manager.register_manager_font( manager_id=self.plugin_id, element_key=f"{self.plugin_id}.message", - family="press_start", - size_px=10, + family=self.MESSAGE_FONT[0], + size_px=self.MESSAGE_FONT[1], color=self.color ) @@ -67,8 +74,8 @@ def _register_fonts(self): font_manager.register_manager_font( manager_id=self.plugin_id, element_key=f"{self.plugin_id}.time", - family="press_start", - size_px=8, + family=self.TIME_FONT[0], + size_px=self.TIME_FONT[1], color=self.time_color ) @@ -76,6 +83,29 @@ def _register_fonts(self): except Exception as e: self.logger.warning(f"Error registering fonts: {e}") + def _fitted_font(self, font_manager, element_key, spec, text, width): + """The registered face, stepped down until `text` fits `width`. + + The registered sizes suit a 128px-wide panel. Asking for them + unconditionally overflowed 64x32, 64x64 and 96x48 -- which only became + visible once the font-manager branch started working at all, since the + bundled BDF it used to fall back to is smaller. Measure and shrink + rather than assume a panel size. + """ + family, size = spec + usable = max(1, width - 2) + for size_px in range(size, 3, -1): + font = font_manager.resolve_font(element_key, family, size_px, + plugin_id=self.plugin_id) + if font is None: + return None + try: + if self.display_manager.get_text_width(text, font) <= usable: + return font + except Exception: # noqa: BLE001 - unmeasurable font, take it as-is + return font + return font + def _load_font(self): """Load the 6x9 BDF font for text rendering.""" if freetype is None: @@ -145,8 +175,19 @@ def display(self, force_clear=False): try: if hasattr(self.plugin_manager, 'font_manager'): font_manager = self.plugin_manager.font_manager - message_font = font_manager.get_font(f"{self.plugin_id}.message") - time_font = font_manager.get_font(f"{self.plugin_id}.time") + # resolve_font() is the accessor for a registered element -- + # it honours user overrides and takes the same (family, + # size_px) the element was registered with. get_font() is + # the lower-level family lookup and needs a family name plus + # a size; calling it with an element key and no size raised + # TypeError on every frame, so this whole branch was dead + # and the bundled BDF was always used instead. + message_font = self._fitted_font( + font_manager, f"{self.plugin_id}.message", + self.MESSAGE_FONT, self.message, width) + time_font = self._fitted_font( + font_manager, f"{self.plugin_id}.time", + self.TIME_FONT, self.current_time_str or "00:00", width) except Exception as e: self.logger.warning(f"Error getting fonts from font manager: {e}") diff --git a/plugins/hello-world/manifest.json b/plugins/hello-world/manifest.json index 40666285..60c0fc80 100644 --- a/plugins/hello-world/manifest.json +++ b/plugins/hello-world/manifest.json @@ -1,7 +1,7 @@ { "id": "hello-world", "name": "Hello World", - "version": "1.1.0", + "version": "1.1.1", "author": "ChuckBuilds", "description": "A simple test plugin that displays a customizable message", "entry_point": "manager.py", @@ -16,6 +16,12 @@ "hello-world" ], "versions": [ + { + "version": "1.1.1", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-02", "version": "1.1.0", diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index c6337a8a..58681c41 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.25.3", + "version": "1.25.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.25.4", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "1.25.3", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 10b05e99..a1d6337c 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -122,6 +122,20 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +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 _logo_needs_refresh(logo_file) -> bool: """True if this file is a placeholder stale enough to retry the real logo. @@ -455,15 +469,32 @@ def _load_custom_font_from_element_config( self.logger.debug(f"Loaded font: {font_name} at size {font_size}") return font elif font_path.lower().endswith('.bdf'): - # BDF fonts are not supported by ImageFont.truetype() - # To use BDF fonts, convert to PILfont format using pilfont.py: - # python -m PIL.pilfont font.bdf - # This creates .pil and .pbm files that can be loaded with ImageFont.load() - self.logger.warning( - f"BDF font '{font_name}' not supported; convert to PILfont format " - f"using 'python -m PIL.pilfont {font_path}' then use the .pil file. " - "Falling back to default font." - ) + # FreeType reads BDF, so truetype() handles a .bdf directly + # -- the core's FontManager and several plugins already do + # this. The old note here claimed otherwise and pointed at + # pilfont.py, so every .bdf face in the picker warned and + # fell back to the default font. + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + return font + except OSError: + # A bitmap face exists at exactly the size it was drawn + # at; FreeType rejects any other with "invalid pixel + # size". Retry at the size the file declares. + native = _bdf_pixel_size(font_path) + if native is not None and native != font_size: + try: + font = ImageFont.truetype(font_path, native) + self.logger.debug( + f"Loaded BDF font {font_name} at its native size {native} " + f"(requested {font_size})") + return font + except OSError: + pass + self.logger.warning( + f"Could not load BDF font '{font_name}' at {font_size} " + "or its native size; falling back to default font.") # Fall through to default else: self.logger.warning(f"Unknown font file type: {font_name}, using default") diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 4d25bd29..c9ab9f94 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -72,6 +72,20 @@ def _resolve_font_path(path: str) -> str: RESAMPLE_FILTER = Image.LANCZOS +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 + + class GameRenderer(SportsGameRendererMixin): """ Renders individual game cards as PIL Images for display. @@ -220,15 +234,24 @@ def _load_custom_font(self, element_config: Dict[str, Any], default_size: int = if font_path.lower().endswith(('.ttf', '.otf')): return ImageFont.truetype(font_path, font_size) elif font_path.lower().endswith('.bdf'): - # ImageFont.truetype does not support bitmap (BDF) fonts. - # Use ImageFont.load for BDFs; note that BDFs are bitmap - # fonts and ignore font_size — the glyph size is baked in. + # FreeType reads BDF directly. The pre-conversion dance + # below assumed otherwise; no .pil ships anywhere, so every + # .bdf in the picker fell back to the default font. try: - return ImageFont.load(font_path) - except Exception as e: + return ImageFont.truetype(font_path, font_size) + except OSError: + # A bitmap face exists at exactly the size it was drawn + # at; FreeType rejects any other with "invalid pixel + # size". Retry at the size the file declares. + native = _bdf_pixel_size(font_path) + if native is not None and native != font_size: + try: + return ImageFont.truetype(font_path, native) + except OSError: + pass self.logger.warning( - f"Could not load BDF font {font_name}: {e}; using default" - ) + f"Could not load BDF font {font_name} at {font_size} " + f"or its native size") except Exception as e: self.logger.error(f"Error loading font {font_name}: {e}") diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 545c7fc1..d95bac9f 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.24.4", + "version": "1.24.5", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.24.5", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "1.24.4", @@ -360,7 +366,7 @@ { "released": "2026-07-02", "version": "1.3.0", - "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores \u2014 spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", + "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores — spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index e5018304..a13abc39 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -122,6 +122,20 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +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 _logo_needs_refresh(logo_file) -> bool: """True if this file is a placeholder stale enough to retry the real logo. @@ -456,15 +470,32 @@ def _load_custom_font_from_element_config( self.logger.debug(f"Loaded font: {font_name} at size {font_size}") return font elif font_path.lower().endswith('.bdf'): - # BDF fonts are not supported by ImageFont.truetype() - # To use BDF fonts, convert to PILfont format using pilfont.py: - # python -m PIL.pilfont font.bdf - # This creates .pil and .pbm files that can be loaded with ImageFont.load() - self.logger.warning( - f"BDF font '{font_name}' not supported; convert to PILfont format " - f"using 'python -m PIL.pilfont {font_path}' then use the .pil file. " - "Falling back to default font." - ) + # FreeType reads BDF, so truetype() handles a .bdf directly + # -- the core's FontManager and several plugins already do + # this. The old note here claimed otherwise and pointed at + # pilfont.py, so every .bdf face in the picker warned and + # fell back to the default font. + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + return font + except OSError: + # A bitmap face exists at exactly the size it was drawn + # at; FreeType rejects any other with "invalid pixel + # size". Retry at the size the file declares. + native = _bdf_pixel_size(font_path) + if native is not None and native != font_size: + try: + font = ImageFont.truetype(font_path, native) + self.logger.debug( + f"Loaded BDF font {font_name} at its native size {native} " + f"(requested {font_size})") + return font + except OSError: + pass + self.logger.warning( + f"Could not load BDF font '{font_name}' at {font_size} " + "or its native size; falling back to default font.") # Fall through to default else: self.logger.warning(f"Unknown font file type: {font_name}, using default") diff --git a/plugins/ledmatrix-flights/test_vegas_map_parity.py b/plugins/ledmatrix-flights/test_vegas_map_parity.py index 08f9f7c2..b15060a7 100644 --- a/plugins/ledmatrix-flights/test_vegas_map_parity.py +++ b/plugins/ledmatrix-flights/test_vegas_map_parity.py @@ -46,6 +46,19 @@ def info(self, *a, **k): def warning(self, *a, **k): pass + def error(self, *a, **k): + pass + + def exception(self, *a, **k): + pass + + # The plugin guards expensive debug formatting behind isEnabledFor, which + # every real logger has. Reporting False keeps that formatting out of the + # test, and stops the guard raising AttributeError on a stand-in that only + # implemented the three methods it happened to need at the time. + def isEnabledFor(self, level): + return False + class FakeDisplayManager: def __init__(self, width=W, height=H): @@ -261,8 +274,12 @@ def enable_map_background(plugin, tmp_path): calls = [] - def fake_fetch(x, y, zoom): - calls.append((x, y, zoom)) + # allow_network mirrors _fetch_tile's real signature -- manager.py calls it + # by keyword, and a three-argument stand-in raised TypeError on every path + # that reached it. Recorded alongside the coordinates so a test can assert + # the cache-only render never asks to open a socket. + def fake_fetch(x, y, zoom, allow_network=True): + calls.append((x, y, zoom, allow_network)) # A gradient rather than a flat fill, so a wrongly-scaled crop shows up. tile = Image.new('RGB', (plugin.tile_size, plugin.tile_size)) tile.putdata([ diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index 533d1e72..f3ea051f 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -467,7 +467,16 @@ def _warn_if_content_will_be_truncated(self, image_width: int) -> None: if required <= budget: return + # Measure the shortfall over the same distance `required` used -- + # content plus the lead-in -- not over image_width alone. Mixing the + # two under-reported how much is lost, and when only the safety + # buffer pushed `required` over the budget the subtraction went + # negative and the clamp printed the self-contradictory "roughly the + # last 0% of the list will not be reached". + total_travel = image_width + self.display_width shown_px = budget * pixels_per_second + if shown_px >= total_travel: + return # fits without the buffer; not worth a warning limiter = ("the core's display.dynamic_duration.max_duration_seconds" if core_cap <= min(self.max_duration, self.dynamic_duration_cap) else "this plugin's global.dynamic_duration settings") @@ -477,7 +486,7 @@ def _warn_if_content_will_be_truncated(self, image_width: int) -> None: "of the list will not be reached before the display moves on. Raise that " "cap, increase the scroll speed, or lower top_teams.", image_width, required, pixels_per_second, budget, limiter, - max(0.0, 100.0 * (1.0 - shown_px / max(image_width, 1))), + 100.0 * (total_travel - shown_px) / max(image_width, 1), ) except Exception as e: # pragma: no cover - diagnostics only self.logger.debug("Could not evaluate content duration budget: %s", e) diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 0d0e7131..ec320632 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.3.6", + "version": "1.3.7", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "version": "1.3.7", + "released": "2026-09-07", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-07", "version": "1.3.6", diff --git a/plugins/ledmatrix-music/config_schema.json b/plugins/ledmatrix-music/config_schema.json index b2fb081b..35633805 100644 --- a/plugins/ledmatrix-music/config_schema.json +++ b/plugins/ledmatrix-music/config_schema.json @@ -225,8 +225,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -267,8 +266,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "5x7.bdf" }, @@ -309,8 +307,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "5x7.bdf" }, diff --git a/plugins/ledmatrix-music/manifest.json b/plugins/ledmatrix-music/manifest.json index d916eac8..228fd87e 100644 --- a/plugins/ledmatrix-music/manifest.json +++ b/plugins/ledmatrix-music/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-music", "name": "Music Player - Now Playing", - "version": "1.4.0", + "version": "1.4.1", "description": "Real-time now playing display for Spotify and YouTube Music with album art, scrolling text, and progress bars", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -66,6 +66,12 @@ } ], "versions": [ + { + "version": "1.4.1", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "version": "1.4.0", "released": "2026-09-03", diff --git a/plugins/nfl-draft/manager.py b/plugins/nfl-draft/manager.py index 272368bc..25efae79 100644 --- a/plugins/nfl-draft/manager.py +++ b/plugins/nfl-draft/manager.py @@ -1225,12 +1225,19 @@ def update(self) -> None: except Exception as e: self.logger.error(f"Error updating draft data: {e}", exc_info=True) - def display(self, force_clear: bool = False) -> None: + def display(self, force_clear: bool = False) -> bool: """ Render the draft picks to the LED matrix. Uses ScrollHelper to create smooth horizontal scrolling. + Returns False when there is nothing to show, so the display controller + rotates on instead of holding the frame. The controller only skips on a + boolean False (display_controller.py checks isinstance(result, bool)), + so returning None left the panel black for the whole display_duration -- + 60s by default, every rotation, for the ~11 months a year that are not + draft season. + Args: force_clear: If True, clear display before rendering """ @@ -1241,17 +1248,17 @@ def display(self, force_clear: bool = False) -> None: picks_loaded = bool(self.draft_picks) status = self.draft_status - # Off-season / expired post-draft window: render nothing + # Off-season / expired post-draft window: render nothing. No need to + # paint the blank frame first -- returning False means the controller + # never shows it. if status == "complete" and not self._is_post_draft_window(): - self._display_blank() - return + return False if status not in ("live", "complete", "simulate") and self._is_off_season(): - self._display_blank() - return + return False if not picks_loaded: self._display_no_data() - return + return True try: # Vegas clears scroll_helper.cached_image whenever this plugin reports @@ -1273,10 +1280,13 @@ def display(self, force_clear: bool = False) -> None: # Set image to display manager self.display_manager.image = visible_image self.display_manager.update_display() + return True + return False except Exception as e: self.logger.error(f"Error displaying draft: {e}") self._display_error() + return True def _display_blank(self) -> None: """Render a solid black frame (off-season silence — no text, no errors).""" diff --git a/plugins/nfl-draft/manifest.json b/plugins/nfl-draft/manifest.json index 0068e377..686046ec 100644 --- a/plugins/nfl-draft/manifest.json +++ b/plugins/nfl-draft/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-draft", "name": "NFL Draft", - "version": "1.4.6", + "version": "1.4.7", "author": "ChuckBuilds", "description": "Displays projected NFL draft picks from ESPN with live draft tracking support during the annual NFL Draft event. Includes simulate_live mode to replay a completed draft using real ESPN core API data. Shows team logos, player names, positions, and pick numbers in a scrolling display.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ], "icon": "fas fa-football-ball", "versions": [ + { + "version": "1.4.7", + "released": "2026-09-07", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-07", "version": "1.4.6", diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 7d7193f6..e6c1f9c0 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -772,8 +772,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -826,8 +825,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -880,8 +878,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -934,8 +931,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -988,8 +984,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -1081,8 +1076,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index b0ddc99e..b4a7ee7e 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.21.3", + "version": "1.21.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.21.4", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "1.21.3", diff --git a/plugins/olympics/data/olympics_api.py b/plugins/olympics/data/olympics_api.py index 4cc3833d..292dfadf 100644 --- a/plugins/olympics/data/olympics_api.py +++ b/plugins/olympics/data/olympics_api.py @@ -34,14 +34,47 @@ def _utcnow() -> datetime: logger = logging.getLogger(__name__) -# Current Olympics configuration -CURRENT_OLYMPICS = { - 'name': 'Milano Cortina 2026', - 'type': 'winter', - 'opening': datetime(2026, 2, 6), - 'closing': datetime(2026, 2, 22), - 'base_url': 'https://www.olympics.com/en/milano-cortina-2026', -} +# Known Games, oldest first. current_games() walks this and returns the one in +# progress, or the next one still to come. +# +# This used to be a single CURRENT_OLYMPICS dict pinned to Milano Cortina 2026. +# Once those Games closed the countdown simply went negative -- the plugin +# rendered "-210 DAYS UNTIL WINTER OLYMPICS" and would have kept counting +# downwards for ever. +# +# Add each Games here as its dates are confirmed. When the table runs out the +# plugin reports no Games rather than inventing a date, so the failure mode is a +# skipped screen and a log line, not a wrong one. +OLYMPIC_GAMES = [ + { + 'name': 'Milano Cortina 2026', + 'type': 'winter', + 'opening': datetime(2026, 2, 6), + 'closing': datetime(2026, 2, 22), + 'base_url': 'https://www.olympics.com/en/milano-cortina-2026', + }, + { + 'name': 'Los Angeles 2028', + 'type': 'summer', + 'opening': datetime(2028, 7, 14), + 'closing': datetime(2028, 7, 30), + 'base_url': 'https://www.olympics.com/en/los-angeles-2028', + }, +] + + +def current_games(now: Optional[datetime] = None) -> Optional[Dict[str, Any]]: + """The Games in progress or the next still to come; None if none are known. + + Returning None is deliberate: it lets the caller show nothing, which is + honest, instead of counting down to a date that has passed. + """ + if now is None: + now = _utcnow() + for games in OLYMPIC_GAMES: + if now <= games['closing']: + return games + return None # Cache configuration (in seconds) CACHE_DURATION = { @@ -286,7 +319,11 @@ def _scrape_medals(self) -> List[MedalCount]: the key 'result_medals_data'. This is much more reliable than trying to parse the client-side rendered HTML. """ - url = f"{CURRENT_OLYMPICS['base_url']}/medals" + games = current_games() + if games is None: + logger.info("No upcoming Olympics in OLYMPIC_GAMES; nothing to fetch") + return [] + url = f"{games['base_url']}/medals" page_content = self._fetch_page(url) if not page_content: @@ -594,7 +631,11 @@ def _scrape_schedule(self) -> List[OlympicEvent]: the key 'result_schedule_data'. Each day has its own key like 'initialSchedule_2026-02-08'. """ - url = f"{CURRENT_OLYMPICS['base_url']}/schedule" + games = current_games() + if games is None: + logger.info("No upcoming Olympics in OLYMPIC_GAMES; nothing to fetch") + return [] + url = f"{games['base_url']}/schedule" page_content = self._fetch_page(url) if not page_content: @@ -878,7 +919,11 @@ def _scrape_results(self) -> List[EventResult]: The medals page contains individual medal winners which we can group by event to create result entries. """ - url = f"{CURRENT_OLYMPICS['base_url']}/medals" + games = current_games() + if games is None: + logger.info("No upcoming Olympics in OLYMPIC_GAMES; nothing to fetch") + return [] + url = f"{games['base_url']}/medals" page_content = self._fetch_page(url) if not page_content: @@ -999,8 +1044,21 @@ def get_olympics_data(self) -> OlympicsData: OlympicsData with all available information """ now = _utcnow() - opening = CURRENT_OLYMPICS['opening'] - closing = CURRENT_OLYMPICS['closing'] + games = current_games(now) + if games is None: + # Past every Games we know about. Report an empty, inactive package + # so the plugin renders nothing rather than a negative countdown. + logger.warning( + "No Olympics on or after %s in OLYMPIC_GAMES -- add the next " + "Games to data/olympics_api.py", now.date()) + return OlympicsData( + is_active=False, games_name="", games_type="", + opening_date=None, closing_date=None, medal_counts=[], + upcoming_events=[], live_events=[], recent_results=[], + last_updated=now, + ) + opening = games['opening'] + closing = games['closing'] is_active = opening <= now <= closing @@ -1015,8 +1073,8 @@ def get_olympics_data(self) -> OlympicsData: return OlympicsData( is_active=is_active, - games_name=CURRENT_OLYMPICS['name'], - games_type=CURRENT_OLYMPICS['type'], + games_name=games['name'], + games_type=games['type'], opening_date=opening, closing_date=closing, medal_counts=medals, diff --git a/plugins/olympics/manifest.json b/plugins/olympics/manifest.json index e5794984..57a17495 100644 --- a/plugins/olympics/manifest.json +++ b/plugins/olympics/manifest.json @@ -1,7 +1,7 @@ { "id": "olympics", "name": "Olympics", - "version": "2.0.3", + "version": "2.0.4", "author": "ChuckBuilds", "description": "Enhanced Olympics plugin with live medal counts, upcoming events, results, and countdown. Supports Vegas scroll mode and regular display mode.", "category": "sports", @@ -37,6 +37,12 @@ "country_tracking": true }, "versions": [ + { + "version": "2.0.4", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-03", "version": "2.0.3", diff --git a/plugins/olympics/renderers/countdown_renderer.py b/plugins/olympics/renderers/countdown_renderer.py index 281f7f48..329016dd 100644 --- a/plugins/olympics/renderers/countdown_renderer.py +++ b/plugins/olympics/renderers/countdown_renderer.py @@ -146,13 +146,66 @@ def _get_logo_image(self, width: int, height: int) -> Image.Image: return self._draw_olympics_rings(width, height) def _calculate_days_until(self, target_date: datetime) -> int: - """Calculate days until target date.""" + """Whole days from today to target_date, never negative. + + A past target means the caller is working from a stale Games table. + Clamping here keeps "-210 DAYS UNTIL WINTER OLYMPICS" off the panel + whatever the data says; data/olympics_api.current_games() is what + actually picks the right Games. + """ today = date.today() if isinstance(target_date, datetime): target = target_date.date() else: target = target_date - return (target - today).days + return max(0, (target - today).days) + + # Progressively shorter stand-ins, tried in order when a line will not fit. + _SHORTER = { + "DAYS UNTIL": ["DAYS TO", "DAYS"], + "OLYMPICS": ["OLYMPIC", "GAMES"], + "CLOSING": ["CLOSE"], + "OPENING": ["OPEN"], + "WINTER": ["WNTR"], + "SUMMER": ["SMMR"], + } + + def _measure(self, text: str) -> int: + """Width of text in the font draw_text will use, 0 if unmeasurable.""" + try: + dm = self.display_manager + if hasattr(dm, "get_text_width"): + return int(dm.get_text_width(text)) + if hasattr(dm, "draw") and hasattr(dm.draw, "textlength"): + return int(dm.draw.textlength(text, font=getattr(dm, "regular_font", None))) + except (AttributeError, TypeError, ValueError, OSError) as err: + # An unmeasurable font is not an error here -- 0 means "no idea", + # and _fit_lines only shortens text when it knows it must. Narrowed + # from a bare `except Exception: pass` so a real fault in the + # display manager surfaces instead of silently disabling fitting. + logger.debug("Could not measure %r: %s", text, err) + return 0 + + def _fit_lines(self, lines, available: int): + """Swap in shorter wording for any line too wide for `available`. + + Unmeasurable fonts return 0 from _measure and leave the text alone, so + this can only shorten when it knows it has to. + """ + if available <= 0: + return lines + out = [] + for line in lines: + if self._measure(line) <= available: + out.append(line) + continue + replaced = line + for candidate in self._SHORTER.get(line, []): + replaced = candidate + if self._measure(candidate) <= available: + break + out.append(replaced) + return out def render_countdown_card(self, target_date: datetime, _games_name: str, games_type: str, is_closing: bool = False, @@ -257,16 +310,34 @@ def display_countdown(self, target_date: datetime, _games_name: str, left_half_width = width // 2 right_half_width = width - left_half_width - # Draw logo on left - logo_margin = 2 - logo_width = left_half_width - (2 * logo_margin) - logo_height = height - (2 * logo_margin) - logo = self._get_logo_image(logo_width, logo_height) - - if logo.mode == 'RGBA': - self.display_manager.image.paste(logo, (logo_margin, logo_margin), logo) - else: - self.display_manager.image.paste(logo, (logo_margin, logo_margin)) + # The wording is sized for a 256px panel; on anything narrower + # "DAYS UNTIL" and "OLYMPICS" ran off the right edge, clipped mid-word. + # Step down to shorter equivalents until every line fits the half we + # actually have. Measured in the font that will draw them, so this + # follows a font change rather than assuming a character width. + lines = self._fit_lines(lines, right_half_width - 4) + + # On a narrow panel the logo leaves too little for any wording -- at + # 64px wide the right half is 32px, roughly three characters. Give the + # text the whole panel instead of clipping it; a countdown nobody can + # read is worth less than the rings. + show_logo = all( + self._measure(line) <= right_half_width - 4 for line in lines + ) + if not show_logo: + lines = self._fit_lines(lines, width - 4) + + # Draw logo on left (skipped when the text needs the full width) + if show_logo: + logo_margin = 2 + logo_width = left_half_width - (2 * logo_margin) + logo_height = height - (2 * logo_margin) + logo = self._get_logo_image(logo_width, logo_height) + + if logo.mode == 'RGBA': + self.display_manager.image.paste(logo, (logo_margin, logo_margin), logo) + else: + self.display_manager.image.paste(logo, (logo_margin, logo_margin)) # Draw text on right, centered line_height = height // len(lines) @@ -274,7 +345,7 @@ def display_countdown(self, target_date: datetime, _games_name: str, for i, line in enumerate(lines): y = start_y + (i * line_height) - center_x = left_half_width + (right_half_width // 2) + center_x = (left_half_width + (right_half_width // 2)) if show_logo else (width // 2) # Use display_manager's draw_text for proper font handling self.display_manager.draw_text( diff --git a/plugins/on-air/manager.py b/plugins/on-air/manager.py index 6eaade56..74974658 100644 --- a/plugins/on-air/manager.py +++ b/plugins/on-air/manager.py @@ -49,6 +49,11 @@ def _rgb(value, default) -> Tuple[int, int, int]: class OnAirPlugin(BasePlugin): """ON AIR sign driven by MQTT with HA auto-discovery.""" + # Seconds to hold the blank frame when the light is off. Short enough + # that the rotation steps past it, non-zero so the controller still + # gets a frame. + IDLE_DURATION = 1.0 + def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cache_manager, plugin_manager): super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) @@ -204,8 +209,10 @@ def display(self, force_clear: bool = False) -> bool: # When off, render a plain black frame rather than returning False. # Returning False while still in on-demand mode causes the display # controller to fall to its "Initializing" state before the stop - # request is processed. The 1s get_display_duration() cycles past - # this black frame nearly instantly in normal rotation. + # request is processed. get_display_duration() reports IDLE_DURATION + # while the light is off so the rotation cycles past this black frame + # quickly -- the configured display_duration (5s by default, up to 60) + # applies only when there is a tally to show. fill = bg_color if active else (0, 0, 0) canvas = Image.new('RGB', (dw, dh), fill) draw = ImageDraw.Draw(canvas) @@ -263,6 +270,16 @@ def _draw_centered_text(self, draw: ImageDraw.ImageDraw, centered=True) def get_display_duration(self) -> float: + # A dark tally light has nothing to show, so hold it only long enough + # for the rotation to step past. display() deliberately returns True + # while off (see the note there), which means the controller does not + # skip the mode -- without this the panel sat black for the configured + # display_duration, 5s by default and up to 60s, on every rotation of + # an enabled-but-not-broadcasting plugin. + with self.state_lock: + active = self.on_air + if not active: + return self.IDLE_DURATION return float(self.config.get('display_duration', 5)) def on_enable(self) -> None: diff --git a/plugins/on-air/manifest.json b/plugins/on-air/manifest.json index cf8a4334..e18ebca7 100644 --- a/plugins/on-air/manifest.json +++ b/plugins/on-air/manifest.json @@ -1,9 +1,9 @@ { "id": "on-air", "name": "On Air Light", - "version": "1.2.7", + "version": "1.2.8", "author": "ChuckBuilds", - "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live \u2014 stays on until you turn it off.", + "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live — stays on until you turn it off.", "entry_point": "manager.py", "class_name": "OnAirPlugin", "category": "utility", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.2.8", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-03", "version": "1.2.7", diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index b3990cb2..4dc31208 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -5722,8 +5722,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -5776,8 +5775,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -5830,8 +5828,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, @@ -5884,8 +5881,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -5938,8 +5934,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "4x6-font.ttf" }, @@ -6031,8 +6026,7 @@ "4x6-font.ttf", "5by7.regular.ttf", "5x7.bdf", - "4x6.bdf", - "cozette.bdf" + "4x6.bdf" ], "default": "PressStart2P-Regular.ttf" }, diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 94247633..3d47bc77 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.24.4", + "version": "2.24.5", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -21,11 +21,44 @@ "live-scores" ], "display_modes": [ - "soccer_live", - "soccer_recent", - "soccer_upcoming" + "soccer_eng.1_live", + "soccer_eng.1_recent", + "soccer_eng.1_upcoming", + "soccer_esp.1_live", + "soccer_esp.1_recent", + "soccer_esp.1_upcoming", + "soccer_fifa.world_live", + "soccer_fifa.world_recent", + "soccer_fifa.world_upcoming", + "soccer_fra.1_live", + "soccer_fra.1_recent", + "soccer_fra.1_upcoming", + "soccer_ger.1_live", + "soccer_ger.1_recent", + "soccer_ger.1_upcoming", + "soccer_ita.1_live", + "soccer_ita.1_recent", + "soccer_ita.1_upcoming", + "soccer_por.1_live", + "soccer_por.1_recent", + "soccer_por.1_upcoming", + "soccer_uefa.champions_live", + "soccer_uefa.champions_recent", + "soccer_uefa.champions_upcoming", + "soccer_uefa.europa_live", + "soccer_uefa.europa_recent", + "soccer_uefa.europa_upcoming", + "soccer_usa.1_live", + "soccer_usa.1_recent", + "soccer_usa.1_upcoming" ], "versions": [ + { + "version": "2.24.5", + "released": "2026-09-05", + "ledmatrix_min_version": "3.3.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-04", "version": "2.24.4", @@ -337,7 +370,7 @@ { "released": "2026-07-29", "version": "2.5.0", - "notes": "Corrected every team code in TEAMS.md against ESPN's live data \u2014 Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", + "notes": "Corrected every team code in TEAMS.md against ESPN's live data — Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", "ledmatrix_min": "2.0.0" }, { @@ -367,7 +400,7 @@ { "released": "2026-07-02", "version": "2.2.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/soccer-scoreboard/test_favorite_team_diagnostics.py b/plugins/soccer-scoreboard/test_favorite_team_diagnostics.py index 98096960..ebb6a8af 100644 --- a/plugins/soccer-scoreboard/test_favorite_team_diagnostics.py +++ b/plugins/soccer-scoreboard/test_favorite_team_diagnostics.py @@ -74,6 +74,28 @@ def __init__(self, *a, **k): "src.plugin_system.base_plugin": bp_mod, }) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + # + # This file was the one of eight that never got this block, so it broke the + # day sports.py started importing src.common.sports_shared. It looked fine + # because the CI runner was reporting pytest files as passing without + # running them; fixing that runner is what made these 22 failures visible. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not sys.modules["src"].__path__: + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not sys.modules["src.common"].__path__): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + # ESPN's real Premier League codes, as returned by its teams endpoint. PREMIER_LEAGUE = { 'ARS': 'Arsenal', 'AVL': 'Aston Villa', 'BOU': 'AFC Bournemouth', diff --git a/plugins/static-image/manager.py b/plugins/static-image/manager.py index 897de926..4ba0509b 100644 --- a/plugins/static-image/manager.py +++ b/plugins/static-image/manager.py @@ -372,7 +372,16 @@ def _resolve_image_path(self, image_path: str) -> str: project_path = project_root / image_path if project_path.exists(): return str(project_path) - + + # Try relative to this plugin's own directory, so a bundled asset + # resolves whatever the plugins directory is called. Installs are not + # all under "plugins/" -- check_plugin.py takes --plugin-dir and a rig + # may use "plugin-repos/", in which case the project-root attempt above + # silently misses and the image reads as absent. + plugin_path = Path(__file__).resolve().parent / image_path + if plugin_path.exists(): + return str(plugin_path) + # Try as-is in case it's already resolved return image_path diff --git a/plugins/static-image/manifest.json b/plugins/static-image/manifest.json index 5b329973..d230f059 100644 --- a/plugins/static-image/manifest.json +++ b/plugins/static-image/manifest.json @@ -1,7 +1,7 @@ { "id": "static-image", "name": "Static Image Display", - "version": "1.0.7", + "version": "1.0.8", "author": "ChuckBuilds", "description": "Display static images on your LED matrix with automatic scaling, aspect ratio preservation, and transparency support. Perfect for logos, artwork, or custom graphics.", "entry_point": "manager.py", @@ -18,6 +18,12 @@ "static_image" ], "versions": [ + { + "version": "1.0.8", + "released": "2026-09-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Validation-sweep fixes; see the PR for the specific defect." + }, { "released": "2026-09-03", "version": "1.0.7", diff --git a/plugins/static-image/test/harness.json b/plugins/static-image/test/harness.json index c2be0ca7..7d819dfe 100644 --- a/plugins/static-image/test/harness.json +++ b/plugins/static-image/test/harness.json @@ -1,8 +1,8 @@ { - "_comment": "Deterministic harness config: renders a bundled test-pattern PNG (paths resolve relative to the core working directory, then the project root; the harness runs from a core checkout with --plugin-dir pointing here, so an absolute-safe relative path into this plugin's test fixtures is used). fit_to_display scales the 64x32 pattern to each panel deterministically.", + "_comment": "Deterministic harness config: renders a bundled test-pattern PNG (the path is relative to this plugin's own directory, which is the only base that holds however the plugins directory is named -- 'plugins/...' assumed one particular --plugin-dir and silently missed on a rig using plugin-repos/, leaving the render blank and every golden 'drifting'). fit_to_display scales the 64x32 pattern to each panel deterministically.", "config": { "enabled": true, - "images": ["plugins/static-image/test/fixtures/test-pattern.png"], + "images": ["test/fixtures/test-pattern.png"], "fit_to_display": true, "preserve_aspect_ratio": false } diff --git a/scripts/run_plugin_tests.py b/scripts/run_plugin_tests.py index f50baa41..3668349b 100644 --- a/scripts/run_plugin_tests.py +++ b/scripts/run_plugin_tests.py @@ -43,6 +43,7 @@ import argparse import os +import re import subprocess import sys from pathlib import Path @@ -53,6 +54,65 @@ PASS, FAIL, SKIP = 0, 1, 2 +def is_pytest_module(script: Path) -> bool: + """True when a file is a pytest module rather than a standalone script. + + These declare `def test_*` / `class Test*` and have no `__main__` guard, so + running them the way run_one() does merely imports them: the test functions + are defined, none are called, and the process exits 0. That is reported as a + pass, which is how four real failures in ledmatrix-flights and one in news + sat green in CI. Hand such files to pytest instead. + """ + try: + src = script.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + has_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M) + has_main_guard = "__main__" in src and "__name__" in src + return bool(has_items and not has_main_guard) + + +def run_pytest_module(script: Path, core: Path | None, timeout: int) -> tuple[int, str]: + """Run one pytest module and map its outcome onto the pass/skip/fail codes.""" + env = dict(os.environ) + if core: + core = core.resolve() + env["PYTHONPATH"] = f"{core}{os.pathsep}{env.get('PYTHONPATH', '')}" + env["LEDMATRIX_CORE"] = str(core) + try: + # Fixed interpreter (sys.executable) running pytest on a test file this + # script discovered in the repo; argument list, no shell, so nothing is + # word-split or expanded. Same suppression pair the rest of the repo + # uses for this shape (see scripts/render_docs_assets.py). + proc = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep + [sys.executable, "-m", "pytest", script.name, "-q", "--no-header", # nosemgrep + "--tb=line", "-p", "no:cacheprovider"], + cwd=script.parent, env=env, capture_output=True, + text=True, timeout=timeout, stdin=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + return FAIL, f"timed out after {timeout}s" + + out = (proc.stdout or "") + (proc.stderr or "") + summary = "" + for line in reversed(out.splitlines()): + if "passed" in line or "failed" in line or "error" in line: + summary = line.strip("= ").strip() + break + if proc.returncode == 0: + return PASS, "" + # pytest exit 5 is "no tests collected" -- for a file we classified as a + # pytest module that means the classification was wrong, not that the file + # is fine. Surface it rather than swallowing it as a pass. + if proc.returncode == 5: + return FAIL, "pytest collected no tests from a file that looks like a pytest module" + failed = [ln.strip() for ln in out.splitlines() if ln.startswith("FAILED") or ln.startswith("ERROR")] + reason = "; ".join(f[:100] for f in failed[:3]) or summary or f"exit {proc.returncode}" + if len(failed) > 3: + reason += f" (+{len(failed) - 3} more)" + return FAIL, reason + + def run_one(script: Path, core: Path | None, timeout: int) -> tuple[int, str]: env = dict(os.environ) if core: @@ -65,8 +125,10 @@ def run_one(script: Path, core: Path | None, timeout: int) -> tuple[int, str]: # LEDMATRIX_CORE is that contract, and it is absolute. env["LEDMATRIX_CORE"] = str(core) try: - proc = subprocess.run( - [sys.executable, script.name], + # Same contract as run_pytest_module above: fixed interpreter, + # argument list, no shell. + proc = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep + [sys.executable, script.name], # nosemgrep cwd=script.parent, env=env, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL, ) @@ -146,7 +208,10 @@ def main() -> int: continue print(f"\n{pid}") for script in scripts: - status, detail = run_one(script, args.core, args.timeout) + if is_pytest_module(script): + status, detail = run_pytest_module(script, args.core, args.timeout) + else: + status, detail = run_one(script, args.core, args.timeout) totals[status] += 1 label = {PASS: "pass", SKIP: "SKIP", FAIL: "FAIL"}[status] print(f" [{label}] {script.name}" + (f" -- {detail}" if detail else "")) diff --git a/scripts/test_module_helpers_defined.py b/scripts/test_module_helpers_defined.py new file mode 100644 index 00000000..20d82fe2 --- /dev/null +++ b/scripts/test_module_helpers_defined.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""A module-level helper must be defined in every module that calls it. + +Five sports plugins got the same BDF-font fix: load a .bdf through +ImageFont.truetype, and when FreeType rejects the requested size, retry at the +size the file declares. The retry needs a small `_bdf_pixel_size()` reader -- +which was added to one of the five and called in all five: + + except OSError: + native = _bdf_pixel_size(font_path) # NameError in four of them + +Nothing caught it. The call sits inside an `except OSError:` handler, so it +only runs when a BDF is requested at a non-native size -- which is precisely +the case the fix exists for, and precisely the case no test exercised. Every +unit test passed and the plugins imported fine; the fix was simply dead in +four plugins and raised NameError in the one situation it was written for. + +This checks the general invariant rather than that one helper: a call to a +bare `_name(...)` must resolve to something the module actually defines or +imports. `pylint --errors-only` reports the same class of fault (E0602) if you +would rather run that. + +Run: python scripts/test_module_helpers_defined.py +""" + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +#: Names that look module-level but are supplied by the runtime or a star +#: import. Kept explicit so an addition here is a deliberate decision. +ALLOWED_UNDEFINED = { + "__name__", "__file__", "__doc__", "__package__", "__spec__", +} + + +def _bound_names(tree: ast.AST) -> set: + """Every name the module binds: defs, classes, imports, assignments.""" + bound = set(dir(__builtins__)) | ALLOWED_UNDEFINED + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + bound.add(node.name) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + bound.add(node.id) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + bound.add(alias.asname or alias.name.split(".")[0]) + elif isinstance(node, (ast.arg,)): + bound.add(node.arg) + elif isinstance(node, ast.ExceptHandler) and node.name: + bound.add(node.name) + elif isinstance(node, ast.Global): + bound.update(node.names) + return bound + + +def check(path: Path) -> list: + """Underscore-prefixed calls in `path` that nothing in it defines.""" + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return [] + + bound = _bound_names(tree) + missing = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fn = node.func + # Only bare `_name(...)`. An attribute call (self._x, mod._x) resolves + # at runtime against an object this check cannot see. + if isinstance(fn, ast.Name) and fn.id.startswith("_") and fn.id not in bound: + missing.append((node.lineno, fn.id)) + return missing + + +def main() -> int: + files = sorted(ROOT.glob("plugins/*/**/*.py")) + failures = [] + for path in files: + for lineno, name in check(path): + failures.append(f"{path.relative_to(ROOT)}:{lineno}: {name}() is never defined here") + + if failures: + print("A module-level helper is called but not defined.") + print("This raises NameError the first time that line runs:\n") + for f in failures: + print(f" {f}") + print("\nDefine the helper in each module that calls it, or import it.") + return 1 + + print(f"OK: every module-level helper call resolves ({len(files)} files checked)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())