From 081077a899fbd5562b95f61bcd7b64f0a8bcaf9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:57:59 -0400 Subject: [PATCH] fix(scoreboards): tell the shared code where the plugin lives Every grid-snapped font was rendering a pixel narrow on every device. The shared sports code reads this plugin's config_schema.json to tell a default font size from one the user chose: a default is snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which cannot work under the real plugin loader -- PluginLoader._namespace_plugin_modules renames a plugin's modules to "_plg__" and removes the original names, so nothing is left to inspect. The lookup returned nothing, every configured size then looked user-chosen, the snap was skipped, and 4x6-font.ttf drew at 6 instead of 7: 3px-wide glyphs instead of 4px. On a 256x64 panel the betting odds, the team records and the date row were hard to read. Each SportsCore now declares _PLUGIN_DIR from its own __file__, which the shared code prefers over guessing (ChuckBuilds/LEDMatrix#519). scripts/test_plugin_dir_under_loader.py stops this recurring. It reproduces the loader's sequence exactly -- import sports.py under its BARE name, then rename it away -- because that is what makes the class report __module__ == "sports" with no such entry in sys.modules. Both halves matter, and the first draft of this guard got it wrong: it imported under the namespaced name, __module__ still resolved, and it passed against the broken code. Verified the corrected version fails for all eight with the declarations removed and passes with them restored. Found by a user counting pixels on a photo of the panel. No gate here caught it: every test imported plugins directly, which leaves the bare module entry in place, and the safety harness loads plugins its own way. --- plugins.json | 16 +-- plugins/afl-scoreboard/manifest.json | 9 +- plugins/afl-scoreboard/sports.py | 7 + plugins/baseball-scoreboard/manifest.json | 9 +- plugins/baseball-scoreboard/sports.py | 7 + plugins/basketball-scoreboard/manifest.json | 9 +- plugins/basketball-scoreboard/sports.py | 7 + plugins/football-scoreboard/manifest.json | 9 +- plugins/football-scoreboard/sports.py | 7 + plugins/hockey-scoreboard/manifest.json | 9 +- plugins/hockey-scoreboard/sports.py | 7 + plugins/lacrosse-scoreboard/manifest.json | 9 +- plugins/lacrosse-scoreboard/sports.py | 7 + plugins/nrl-scoreboard/manifest.json | 9 +- plugins/nrl-scoreboard/sports.py | 7 + plugins/soccer-scoreboard/manifest.json | 9 +- plugins/soccer-scoreboard/sports.py | 7 + scripts/test_plugin_dir_under_loader.py | 144 ++++++++++++++++++++ 18 files changed, 272 insertions(+), 16 deletions(-) create mode 100755 scripts/test_plugin_dir_under_loader.py diff --git a/plugins.json b/plugins.json index ac1f5e85..f818927f 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.40.0" + "latest_version": "1.40.1" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.29.0" + "latest_version": "1.29.1" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.4.0" + "latest_version": "3.4.1" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.25.0", + "latest_version": "1.25.1", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.24.0", + "latest_version": "1.24.1", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.24.0" + "latest_version": "2.24.1" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.22.0", + "latest_version": "1.22.1", "last_updated": "2026-09-02" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.21.0" + "latest_version": "1.21.1" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 68f04d74..63636dcb 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.0", + "version": "1.22.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.22.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Documentation only, no behaviour change. Rewrites the README around real rendered screenshots of the 2026 finals series and documents every configuration option. Writes down the game-selection logic in full: the three selection paths, and that upcoming_games_to_show/recent_games_to_show mean a per-team budget under show_favorite_teams_only but a total otherwise. Records four verified dead ends so they stop costing people time: show_odds is a no-op for AFL because ESPN publishes no odds block for the league (though it still issues one odds request per selected game), show_ranking has no poll to draw, dynamic_duration.min_duration_seconds is never read, and background_service.max_workers is ignored in favour of a single worker. Also corrects README defaults that had drifted from the schema (show_favorite_teams_only, display_duration, show_odds)." + }, { "version": "1.22.0", "released": "2026-09-02", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 79786021..bfa934e0 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -156,6 +156,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c529f075..042f7514 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.0", + "version": "1.40.1", "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,13 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.40.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.40.0", "released": "2026-09-02", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index c341f898..d68528b7 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -167,6 +167,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index b6460ab6..151dec6d 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.0", + "version": "1.29.1", "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,13 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.29.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.29.0", "released": "2026-09-02", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 09abe8ae..e6c18873 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -157,6 +157,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + TOURNAMENT_ROUND_ORDER: ClassVar[Dict[str, int]] = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6} def __init__( diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 3c344cb2..2d928979 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.4.0", + "version": "3.4.1", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.4.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "3.4.0", "released": "2026-09-02", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index cf53054d..7cbcac10 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -237,6 +237,13 @@ def _fetch(cls, key, sport_key, team_id, abbr, logo_path, logo_url, logger): class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 7e39b762..a3f6894a 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.0", + "version": "1.25.1", "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,13 @@ } ], "versions": [ + { + "version": "1.25.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.25.0", "released": "2026-09-02", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 12678306..60577dcf 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -154,6 +154,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 75cec242..a115a0de 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.0", + "version": "1.24.1", "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,13 @@ } ], "versions": [ + { + "version": "1.24.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.24.0", "released": "2026-09-02", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 3b784199..14ff178e 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -154,6 +154,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 4a017d12..a2b2a297 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.0", + "version": "1.21.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.21.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.21.0", "released": "2026-09-02", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 96f54ebe..a4e9bc18 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -156,6 +156,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 42c61a18..6977cc39 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.0", + "version": "2.24.1", "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", @@ -26,6 +26,13 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.24.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "Fix every grid-snapped font rendering a pixel narrow. The shared code reads this plugin's config_schema.json to tell a default font size from one the user chose; a default gets snapped to the font's pixel grid, a choice is left alone. It located the schema by inspecting loaded modules, which fails under the real plugin loader -- the loader renames a plugin's modules and removes their original names, so nothing was left to inspect. The lookup returned nothing, every size then looked user-chosen, and the snap was skipped: 4x6-font.ttf drew at 6 instead of 7, which is 3-pixel-wide glyphs instead of 4. On a 256x64 panel the betting odds, team records and the date row were hard to read. The plugin now tells the shared code where it lives instead of leaving it to guess.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "2.24.0", "released": "2026-09-02", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 60814246..00e5643a 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -163,6 +163,13 @@ def _logo_needs_refresh(logo_file) -> bool: class SportsCore(SportsCoreSharedMixin, ABC): + #: Absolute path of this plugin, handed to the shared mixin. It cannot + #: deduce it: __file__ there is src/common/, and inferring the directory + #: from the MRO returns None under the real plugin loader, which silently + #: disabled the schema lookup and shrank every grid-snapped font by a + #: pixel. See SportsCoreSharedMixin._plugin_dir. + _PLUGIN_DIR: ClassVar[str] = os.path.dirname(os.path.abspath(__file__)) + def __init__( self, config: Dict[str, Any], diff --git a/scripts/test_plugin_dir_under_loader.py b/scripts/test_plugin_dir_under_loader.py new file mode 100755 index 00000000..0f9cc4dc --- /dev/null +++ b/scripts/test_plugin_dir_under_loader.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Every scoreboard must find its own config_schema.json the way the DISPLAY +loads it, not the way a test does. + +WHY THIS EXISTS +--------------- +src/common/sports_shared.py resolves a plugin's directory to read its +config_schema.json. That lookup feeds _schema_font_size, which decides whether +a configured font size equals the schema default. If it does, the size is a +default and gets snapped to the font's pixel grid; if it does not, it is the +user's choice and is left alone. + +When the lookup fails it returns None, so *every* configured size stops looking +like a default and skips the snap. 4x6-font.ttf then renders at 6 instead of 7 +-- 3px-wide glyphs instead of 4px. On a 256x64 panel that made the odds, the +team records and the date row hard to read. + +That shipped. It was found by a user counting pixels on a photo of the panel, +not by any gate, because: + + PluginLoader._namespace_plugin_modules renames every bare module a plugin + brought in (sports, game_renderer, ...) to "_plg__" and + REMOVES the bare sys.modules entry. + +A class defined in sports.py still reports __module__ == "sports", but +sys.modules["sports"] is gone. Tests that import the plugin directly leave the +bare entry in place and never see it. This guard reproduces the rename, which +is the whole point. + + python scripts/test_plugin_dir_under_loader.py + +Exit 0 pass, 2 skip (no core checkout), 1 fail. +""" +from __future__ import annotations + +import importlib.util +import logging +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = ["afl", "baseball", "basketball", "football", + "hockey", "lacrosse", "nrl", "soccer"] + + +def core_root(): + env = os.environ.get("LEDMATRIX_CORE") + if env and (Path(env) / "src").is_dir(): + return Path(env) + for cand in (REPO.parent / "LEDMatrix", Path.home() / "LEDMatrix"): + if (cand / "src").is_dir(): + return cand + return None + + +def load_like_the_loader(plugin_id: str, plugin_dir: Path): + """Import sports.py under its BARE name, then rename it away. + + Both halves matter. The bare import is what makes the class report + ``__module__ == "sports"``; the rename is what removes that key from + sys.modules. Import it under a namespaced name instead and __module__ + points at a module that still exists, the MRO walk succeeds, and the guard + passes while production fails -- which is exactly what the first draft of + this file did. + """ + safe = plugin_id.replace("-", "_") + for stale in ("sports", f"_plg_{safe}_sports"): + sys.modules.pop(stale, None) + + sys.path.insert(0, str(plugin_dir)) + try: + spec = importlib.util.spec_from_file_location("sports", plugin_dir / "sports.py") + mod = importlib.util.module_from_spec(spec) + sys.modules["sports"] = mod # bare, as the plugin's own import does + spec.loader.exec_module(mod) + finally: + sys.path.remove(str(plugin_dir)) + + # PluginLoader._namespace_plugin_modules: move it aside, drop the bare key. + sys.modules[f"_plg_{safe}_sports"] = mod + del sys.modules["sports"] + return mod + + +def main() -> int: + core = core_root() + if core is None: + print(" [skip] no LEDMatrix core checkout (set LEDMATRIX_CORE)") + return 2 + sys.path.insert(0, str(core)) + try: + import src.common.sports_shared # noqa: F401 + except Exception as exc: # noqa: BLE001 + print(f" [skip] core has no sports_shared yet: {exc}") + return 2 + + failures = [] + for short in PLUGINS: + plugin_id = f"{short}-scoreboard" + pdir = REPO / "plugins" / plugin_id + if not (pdir / "sports.py").is_file(): + continue + try: + mod = load_like_the_loader(plugin_id, pdir) + except Exception as exc: # noqa: BLE001 + failures.append(f"{plugin_id}: import failed -- {type(exc).__name__}: {exc}") + continue + + base = getattr(mod, "SportsCore", None) + if base is None: + failures.append(f"{plugin_id}: no SportsCore") + continue + probe = type("Probe", (base,), { + "_extract_game_details": lambda s, *a, **k: None, + "_fetch_data": lambda s, *a, **k: None}) + inst = probe.__new__(probe) + inst.logger = logging.getLogger("plugin_dir_probe") + + found = inst._plugin_dir() + if not found: + failures.append( + f"{plugin_id}: _plugin_dir() is None under the loader's module " + f"renaming -- every font size will skip its grid snap") + continue + if Path(found).resolve() != pdir.resolve(): + failures.append(f"{plugin_id}: _plugin_dir() -> {found}, expected {pdir}") + continue + size = inst._schema_font_size("detail_text") + if size is None: + failures.append(f"{plugin_id}: schema found but detail_text size is None") + continue + print(f" [pass] {plugin_id:<22} dir ok, detail_text default = {size}") + + if failures: + for f in failures: + print(f" [FAIL] {f}") + return 1 + print(f" {len(PLUGINS)} scoreboards resolve their schema under the real loader") + return 0 + + +if __name__ == "__main__": + sys.exit(main())