From 549b12eb18bbce37720a77cb8512ac7b9825386d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:31:04 -0400 Subject: [PATCH] feat(scoreboards): give the odds their own font, size and colour The betting line and over/under borrowed detail_text, so enlarging them on a big panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless it is set. The defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this existed falls back to detail_text. All 176 safety-harness renders are byte-identical to the previous version. Three things the tests caught that reading the diff did not: - _element_color is not universally available. It exists on SportsCore, but the plugins' own probe harnesses build minimal managers that borrow only _draw_dynamic_odds. Calling it raised, the surrounding except swallowed it, and the odds silently disappeared. The colour now goes through _odds_color(), which falls back to green when the helper is absent, so a missing dependency degrades to today's rendering instead of dropping the odds. - The odds slot initially read detail_config, so the colour setting worked and the font setting did nothing. - On football the element mapping has to be added to _FONT_ELEMENT_KEYS as well as _ELEMENT_FOR_FONT; its style-resolver path reads the former. test_element_text_colors.py asserted that every advertised text_color default is white, because the store materialises every schema default into config.json and a materialised default must be indistinguishable from unset. odds_text is exempted rather than changed: green IS its unset value, so advertising white would both lie and repaint the odds the moment the store writes it. A second check asserts odds_text advertises exactly that green, so the exemption cannot drift. ufc-scoreboard is deliberately not included. Its loader takes no element_key, it has no per-element colour system, and after adapting for both its ufc_upcoming renders still differed from main reproducibly with no odds pixels involved -- unexplained, so not shipped. Known and pre-existing, not introduced here: on football, setting the font to the alias "four_by_six" falls back to PressStart2P on the style-resolver path. It does the same on main for detail_text. Use the filename "4x6-font.ttf". Floor stays at 3.2.0 -- no new core imports, so this is independent of the sports consolidation work. --- plugins.json | 18 ++++---- plugins/afl-scoreboard/config_schema.json | 39 +++++++++++++++++ plugins/afl-scoreboard/game_renderer.py | 28 ++++++++++-- plugins/afl-scoreboard/manifest.json | 9 +++- plugins/afl-scoreboard/sports.py | 32 +++++++++++--- .../test_element_text_colors.py | 11 ++++- .../baseball-scoreboard/config_schema.json | 39 +++++++++++++++++ plugins/baseball-scoreboard/game_renderer.py | 26 ++++++++++- plugins/baseball-scoreboard/manifest.json | 15 +++++-- plugins/baseball-scoreboard/sports.py | 32 +++++++++++--- .../test_element_text_colors.py | 11 ++++- .../basketball-scoreboard/config_schema.json | 39 +++++++++++++++++ .../basketball-scoreboard/game_renderer.py | 28 ++++++++++-- plugins/basketball-scoreboard/manifest.json | 11 ++++- plugins/basketball-scoreboard/sports.py | 32 +++++++++++--- .../test_element_text_colors.py | 11 ++++- .../football-scoreboard/config_schema.json | 43 ++++++++++++++++++- plugins/football-scoreboard/game_renderer.py | 39 +++++++++++++++-- plugins/football-scoreboard/manifest.json | 11 ++++- plugins/football-scoreboard/sports.py | 32 +++++++++++--- .../test_element_text_colors.py | 11 ++++- .../test_odds_text_formatting.py | 5 +++ plugins/hockey-scoreboard/config_schema.json | 39 +++++++++++++++++ plugins/hockey-scoreboard/game_renderer.py | 5 +++ plugins/hockey-scoreboard/manifest.json | 9 +++- plugins/hockey-scoreboard/sports.py | 34 ++++++++++++--- .../test_element_text_colors.py | 11 ++++- .../lacrosse-scoreboard/config_schema.json | 39 +++++++++++++++++ plugins/lacrosse-scoreboard/game_renderer.py | 28 ++++++++++-- plugins/lacrosse-scoreboard/manifest.json | 11 ++++- plugins/lacrosse-scoreboard/sports.py | 34 ++++++++++++--- .../test_element_text_colors.py | 11 ++++- plugins/nrl-scoreboard/config_schema.json | 39 +++++++++++++++++ plugins/nrl-scoreboard/game_renderer.py | 28 ++++++++++-- plugins/nrl-scoreboard/manifest.json | 9 +++- plugins/nrl-scoreboard/sports.py | 32 +++++++++++--- .../test_element_text_colors.py | 11 ++++- plugins/soccer-scoreboard/config_schema.json | 39 +++++++++++++++++ plugins/soccer-scoreboard/game_renderer.py | 28 ++++++++++-- plugins/soccer-scoreboard/manifest.json | 13 ++++-- plugins/soccer-scoreboard/sports.py | 32 +++++++++++--- .../test_element_text_colors.py | 11 ++++- 42 files changed, 889 insertions(+), 96 deletions(-) diff --git a/plugins.json b/plugins.json index f900d57c..8c60ca8e 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-09-02", + "last_updated": "2026-09-03", "plugins": [ { "id": "cricket-scoreboard", @@ -76,7 +76,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.37.2" + "latest_version": "1.38.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.26.1" + "latest_version": "1.27.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.1.1" + "latest_version": "3.2.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.22.1", + "latest_version": "1.23.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.21.1", + "latest_version": "1.22.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.21.1" + "latest_version": "2.22.0" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.19.3", + "latest_version": "1.20.0", "last_updated": "2026-09-02" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.18.1" + "latest_version": "1.19.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index fb4081d5..c9ea324b 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -1042,6 +1042,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index c9888ff2..b10b30f7 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -133,6 +133,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -141,6 +144,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') # Not user-customizable (no config_schema.json entry) -- fixed # size, loaded once here instead of per-render in @@ -425,6 +429,7 @@ def _calculate_max_logo_dimensions( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", @@ -1128,6 +1133,23 @@ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: fill=self._element_color(bottom_color) ) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning.""" try: @@ -1178,7 +1200,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None # widest time string the centre can hold, measured in the font the # renderer will actually use, so this tracks font changes instead # of hard-coding a width. - font = self.fonts["detail"] + font = self.fonts.get("odds") or self.fonts["detail"] time_font = self.fonts.get("time", font) centre_reserve = draw.textlength("12:00 PM", font=time_font) side_budget = max(0.0, (self.display_width - centre_reserve) / 2) @@ -1197,7 +1219,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color()) # Show over/under on opposite side over_under = odds.get("over_under") @@ -1225,7 +1247,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_x = 0 + odds_x_offset ou_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color()) except Exception as e: self.logger.error(f"Error drawing odds: {e}") diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 5ee31fdf..d6fda907 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.19.3", + "version": "1.20.0", "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.20.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "changelog": "Documentation only. Fills gaps left by the previous README rewrite. Documents other_games_min_quality and other_games_divisions, which the rewrite dropped entirely, and records that both are inert in this league: 'ranked' needs a national poll the AFL does not publish, and the divisions filter needs ESPN's FBS/FCS group rosters, which exist for college football and nothing else. Adds that both filters fail open and that favourites are never filtered by either. Corrects the framing of upcoming_games_to_show: it is a pool the panel cycles through one card at a time, so raising it makes any one game come round less often, not more. Restores the debug-logging tip for finding team abbreviations and a manual-installation section." + }, { "version": "1.19.3", "released": "2026-09-02", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index b15bf2dd..1c38814d 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -1353,6 +1353,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1361,6 +1364,7 @@ def _load_fonts(self): fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6, element_key='detail_text', default_font='4x6-font.ttf') + fonts["odds"] = self._load_custom_font_from_element_config(odds_config, default_size=6, element_key='odds_text', default_font='4x6-font.ttf') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1393,6 +1397,23 @@ def _load_fonts(self): # narrower FACE rather than let a grown score crowd out the logos. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1457,7 +1478,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1465,7 +1486,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width # Top right spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1475,7 +1496,7 @@ def _draw_dynamic_odds( spread_x = 0 # Top left spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1485,7 +1506,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1511,7 +1532,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1522,6 +1543,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/afl-scoreboard/test_element_text_colors.py b/plugins/afl-scoreboard/test_element_text_colors.py index 9f972ff5..0f93f1bf 100644 --- a/plugins/afl-scoreboard/test_element_text_colors.py +++ b/plugins/afl-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 011eaecf..54353b4d 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -1936,6 +1936,45 @@ }, "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "layout": { "type": "object", "title": "Layout Positioning", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 893f0c71..7c4829c8 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -138,6 +138,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -146,6 +149,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") except Exception: @@ -352,6 +356,7 @@ def _load_and_resize_logo(self, league: str, team_abbrev: str) -> Optional[Image #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", @@ -1333,6 +1338,23 @@ def _odds_would_hit_top_row(self, span, placements) -> bool: return any(x < right + 1 and x + width > left - 1 for _text, x, width in placements) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None, top_span=_DERIVE_TOP_SPAN) -> None: """Draw odds with dynamic positioning based on favored team. @@ -1391,7 +1413,7 @@ def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None, # soccer and the rest. Odds are drawn hard left and hard right # while the inning sits centred, so they only meet on a narrow # panel -- and odds_y_offset is there to nudge it when they do. - font = self.fonts['detail'] + font = self.fonts.get('odds') or self.fonts['detail'] # Work out both texts and their spans before drawing either, so the # row can be chosen once with full knowledge of what has to fit. @@ -1441,7 +1463,7 @@ def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None, odds_y += row for text, x, _width in placements: - self._draw_text_with_outline(draw, text, (x, odds_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, text, (x, odds_y), font, fill=self._odds_color()) except Exception: self.logger.exception("Error drawing odds") diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index cb630c1d..2bf04ce4 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.37.2", + "version": "1.38.0", "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.38.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "changelog": "Documentation only, no behaviour change. Rewrites the README around real rendered screenshots and documents every configuration option. Leads with the structural thing that trips people up: almost every setting lives inside a per-league block (mlb/milb/ncaa_baseball), and only nine are plugin-wide, so favorite_teams at the top level does nothing. Explains the three game-selection paths and that upcoming_games_to_show is a per-team budget under show_favorite_teams_only but a total otherwise. Documents the four extra baseball screens (pitcher/batter, last play, player card, traditional scoreboard), all off by default and all costing an extra per-game fetch, and notes show_odds is fetched asynchronously so the line appears a moment after the card is drawn." + }, { "version": "1.37.2", "released": "2026-09-02", @@ -361,7 +368,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" }, { @@ -379,7 +386,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" }, { @@ -421,7 +428,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/sports.py b/plugins/baseball-scoreboard/sports.py index f789ca20..a42d888e 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -1295,6 +1295,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1303,6 +1306,7 @@ def _load_fonts(self): fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6, element_key='detail_text', default_font='4x6-font.ttf') + fonts["odds"] = self._load_custom_font_from_element_config(odds_config, default_size=6, element_key='odds_text', default_font='4x6-font.ttf') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1334,6 +1338,23 @@ def _load_fonts(self): # narrower FACE rather than let a grown score crowd out the logos. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1402,7 +1423,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1410,7 +1431,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width + odds_x_offset spread_y = 0 + odds_y_offset self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1420,7 +1441,7 @@ def _draw_dynamic_odds( spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1430,7 +1451,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1456,7 +1477,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1467,6 +1488,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/baseball-scoreboard/test_element_text_colors.py b/plugins/baseball-scoreboard/test_element_text_colors.py index d96a9129..9444d70c 100644 --- a/plugins/baseball-scoreboard/test_element_text_colors.py +++ b/plugins/baseball-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index cc2e4776..cfb8cae9 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -2578,6 +2578,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index 2ba51731..5e7777ef 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -160,6 +160,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -168,6 +171,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") except Exception: @@ -400,6 +404,7 @@ def _load_and_resize_logo( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", @@ -1129,6 +1134,23 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: except (TypeError, ValueError): return default + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning and user-configurable offsets.""" try: @@ -1171,7 +1193,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None # card (pinned to 128px whatever the panel width) they overprint. # Budget each side against the widest time string the centre can # hold, in the font the renderer will use. - font = self.fonts["detail"] + font = self.fonts.get("odds") or self.fonts["detail"] time_font = self.fonts.get("time", font) centre_reserve = draw.textlength("12:00 PM", font=time_font) side_budget = max(0.0, (self.display_width - centre_reserve) / 2) @@ -1190,7 +1212,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color()) # Show over/under on opposite side over_under = odds.get("over_under") @@ -1217,7 +1239,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_x = 0 + odds_x_offset ou_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color()) except Exception: self.logger.exception("Error drawing odds") diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 67eb1e0b..76649cfc 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.26.1", + "version": "1.27.0", "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.27.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "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.26.1", "released": "2026-09-02", @@ -289,7 +296,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/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 1d9e8b17..c2b8bf3f 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -938,6 +938,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -946,6 +949,7 @@ def _load_fonts(self): fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font_from_element_config(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1268,6 +1272,23 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error getting layout offset for {element}.{axis}: {e}") return default + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1336,7 +1357,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1344,7 +1365,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width + odds_x_offset spread_y = 0 + odds_y_offset self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1354,7 +1375,7 @@ def _draw_dynamic_odds( spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1364,7 +1385,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1390,7 +1411,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1401,6 +1422,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/basketball-scoreboard/test_element_text_colors.py b/plugins/basketball-scoreboard/test_element_text_colors.py index aca706b7..5d11f690 100644 --- a/plugins/basketball-scoreboard/test_element_text_colors.py +++ b/plugins/basketball-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index ba38d086..6732d5cb 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -1221,13 +1221,13 @@ "adaptive" ], "default": "classic", - "description": "Layout engine. 'classic' is the original fixed layout (unchanged). 'adaptive' (beta) scales fonts, logos, and element regions to the panel size \u2014 content grows on large panels and degrades gracefully on small ones. Your customization fonts and x/y offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." + "description": "Layout engine. 'classic' is the original fixed layout (unchanged). 'adaptive' (beta) scales fonts, logos, and element regions to the panel size — content grows on large panels and degrades gracefully on small ones. Your customization fonts and x/y offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." }, "timezone": { "x-advanced": true, "type": "string", "default": "", - "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set. A bare \"UTC\" here is treated as a leftover from the old write-back bug and ignored when your global or system timezone disagrees \u2014 use \"Etc/UTC\" if you really want UTC." + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set. A bare \"UTC\" here is treated as a leftover from the old write-back bug and ignored when your global or system timezone disagrees — use \"Etc/UTC\" if you really want UTC." }, "customization": { "type": "object", @@ -1514,6 +1514,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index b234a308..ae76619f 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -460,7 +460,7 @@ def _load_fonts(self) -> Dict[str, Union[ImageFont.FreeTypeFont, Any]]: if self._style_resolver is not None: for font_key, (loader_font, loader_size) in self._LOADER_DEFAULTS.items(): element = self._FONT_ELEMENT_KEYS.get(font_key, font_key) - if font_key == 'detail': + if font_key in ('detail', 'odds'): # _detail_font_size already picks a size deliberately -- # grid-aligned on tall panels, and off-grid at 6px on 32 # tall ones where the over/under would not survive 7px. @@ -486,6 +486,9 @@ def _load_fonts(self) -> Dict[str, Union[ImageFont.FreeTypeFont, Any]]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -511,6 +514,10 @@ def _load_fonts(self) -> Dict[str, Union[ImageFont.FreeTypeFont, Any]]: detail_config, default_size=self._detail_font_size(), element_key='detail_text', default_font='4x6-font.ttf') + fonts["odds"] = self._load_custom_font( + odds_config, + default_size=self._detail_font_size(), element_key='odds_text', + default_font='4x6-font.ttf') fonts["rank"] = self._load_custom_font( rank_config, default_size=rank_size, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") @@ -686,11 +693,13 @@ def _load_and_resize_logo( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", "status": "status_text", "detail": "detail_text", + "odds": "odds_text", "rank": "rank_text", } @@ -1063,6 +1072,7 @@ def render_game_card( "status": "status_text", "detail": "detail_text", "rank": "rank_text", + "odds": "odds_text", } # Per-element (font, size) the local loader falls back to when a config @@ -1076,6 +1086,9 @@ def render_game_card( 'team': ('PressStart2P-Regular.ttf', 8), 'status': ('PressStart2P-Regular.ttf', 6), 'detail': ('4x6-font.ttf', 6), + # Same default as detail: odds rendered with the detail font until + # this setting existed, and the default must not change that. + 'odds': ('4x6-font.ttf', 6), 'rank': ('PressStart2P-Regular.ttf', 10), } @@ -1092,6 +1105,7 @@ def render_game_card( 'team': ('PressStart2P-Regular.ttf', 8), 'status': ('4x6-font.ttf', 6), 'detail': ('4x6-font.ttf', 6), + 'odds': ('4x6-font.ttf', 6), 'rank': ('PressStart2P-Regular.ttf', 10), } @@ -2197,6 +2211,23 @@ def _centre_row_text(self, game: Dict[str, Any], game_type: str) -> str: _date, time_text = self._upcoming_date_and_time(game) return str(time_text or "") + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], centre_text: str = "") -> None: """Draw odds with dynamic positioning. @@ -2254,7 +2285,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], # the widest time string the centre can hold, measured in the same # font the renderer will use, so this tracks font changes instead # of hard-coding a width. - font = self.fonts["detail"] + font = self.fonts.get("odds") or self.fonts["detail"] time_font = self.fonts.get("time", font) # Reserve for what this card actually puts in the centre, not the # widest string any card could. "12:00 PM" is an upcoming card's @@ -2284,7 +2315,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color()) # Show over/under on opposite side over_under = odds.get("over_under") @@ -2320,7 +2351,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], ou_x = 0 + odds_x_offset ou_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color()) except Exception as e: self.logger.error(f"Error drawing odds: {e}") diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 744ed909..c2709a26 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.1.1", + "version": "3.2.0", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.2.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "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.1.1", "released": "2026-09-02", @@ -389,7 +396,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/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 9448d4aa..152e6311 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -1306,6 +1306,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1314,6 +1317,7 @@ def _load_fonts(self): fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6, element_key='detail_text', default_font='4x6-font.ttf') + fonts["odds"] = self._load_custom_font_from_element_config(odds_config, default_size=6, element_key='odds_text', default_font='4x6-font.ttf') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1345,6 +1349,23 @@ def _load_fonts(self): # that swaps in a narrower FACE when even the grown size overflows. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1412,7 +1433,7 @@ def _draw_dynamic_odds( # for a pick of a week that also produces -3.5 and -46.5, # and the card rendered all three styles side by side. spread_text = "%g" % favored_spread - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1420,7 +1441,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width # Top right spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1430,7 +1451,7 @@ def _draw_dynamic_odds( spread_x = 0 # Top left spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1440,7 +1461,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = "O/U: %g" % over_under - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1466,7 +1487,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1477,6 +1498,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/football-scoreboard/test_element_text_colors.py b/plugins/football-scoreboard/test_element_text_colors.py index 71e81732..a3531953 100644 --- a/plugins/football-scoreboard/test_element_text_colors.py +++ b/plugins/football-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/football-scoreboard/test_odds_text_formatting.py b/plugins/football-scoreboard/test_odds_text_formatting.py index ceb94cb8..50765d23 100644 --- a/plugins/football-scoreboard/test_odds_text_formatting.py +++ b/plugins/football-scoreboard/test_odds_text_formatting.py @@ -31,6 +31,11 @@ def textlength(text, font=None): class _Manager: _draw_dynamic_odds = sports.SportsCore._draw_dynamic_odds + # Borrowed for the same reason as the method above: _draw_dynamic_odds now + # asks for the odds colour, and this probe deliberately carries only the + # surface that method needs. Stubbing it instead would let the real + # resolution regress unnoticed. + _odds_color = sports.SportsCore._odds_color def __init__(self): self.fonts = {"detail": object()} diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 012d5e5f..35d52c74 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -2064,6 +2064,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index 372b252a..64b759d3 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -134,6 +134,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -142,6 +145,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") except Exception: @@ -361,6 +365,7 @@ def _load_and_resize_logo( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 1b2c61bb..55d5b3c8 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.22.1", + "version": "1.23.0", "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.23.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "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.22.1", "released": "2026-09-02", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 80aea088..09c56648 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -1228,6 +1228,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1238,6 +1241,9 @@ def _load_fonts(self): fonts["detail"] = self._load_custom_font_from_element_config( detail_config, default_size=6, default_font="4x6-font.ttf" , element_key='detail_text') + fonts["odds"] = self._load_custom_font_from_element_config( + odds_config, default_size=6, default_font="4x6-font.ttf" + , element_key='odds_text') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1275,6 +1281,23 @@ def _load_fonts(self): # narrower FACE rather than let a grown score crowd out the logos. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1339,7 +1362,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1347,7 +1370,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width # Top right spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1357,7 +1380,7 @@ def _draw_dynamic_odds( spread_x = 0 # Top left spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1367,7 +1390,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1393,7 +1416,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1404,6 +1427,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/hockey-scoreboard/test_element_text_colors.py b/plugins/hockey-scoreboard/test_element_text_colors.py index dceadc50..fe5a3473 100644 --- a/plugins/hockey-scoreboard/test_element_text_colors.py +++ b/plugins/hockey-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index f0bbf1b2..562a3736 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -1469,6 +1469,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 4d8aa9d7..3e7a7c0b 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -134,6 +134,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -142,6 +145,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") except Exception: @@ -370,6 +374,7 @@ def _load_and_resize_logo( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", @@ -1162,6 +1167,23 @@ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: fill=self._element_color(bottom_color) ) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, @@ -1199,7 +1221,7 @@ def _draw_dynamic_odds( favored_spread = away_spread favored_side = "away" - font = self.fonts["detail"] + font = self.fonts.get("odds") or self.fonts["detail"] # Edge-anchored labels share this row with the centred time text, # so on a narrow Vegas card (pinned to 128px) they overprint. # Budget each side against the widest time string the centre holds. @@ -1216,7 +1238,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width - 1 else: spread_x = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, 0), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, 0), font, fill=self._odds_color()) over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): @@ -1232,7 +1254,7 @@ def _draw_dynamic_odds( ou_x = width - ou_width else: ou_x = (width - ou_width) // 2 - self._draw_text_with_outline(draw, ou_text, (ou_x, 0), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, 0), font, fill=self._odds_color()) except Exception as e: self.logger.warning(f"Error drawing odds: {e} | odds={repr(odds)[:120]}") diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 11a9aefe..eaff9977 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.21.1", + "version": "1.22.0", "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.22.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "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.1", "released": "2026-09-02", @@ -302,7 +309,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 3b99dd32..fc0b05ed 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -1229,6 +1229,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1239,6 +1242,9 @@ def _load_fonts(self): fonts["detail"] = self._load_custom_font_from_element_config( detail_config, default_size=6, default_font="4x6-font.ttf" , element_key='detail_text') + fonts["odds"] = self._load_custom_font_from_element_config( + odds_config, default_size=6, default_font="4x6-font.ttf" + , element_key='odds_text') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1276,6 +1282,23 @@ def _load_fonts(self): # narrower FACE rather than let a grown score crowd out the logos. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1340,7 +1363,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1348,7 +1371,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width # Top right spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1358,7 +1381,7 @@ def _draw_dynamic_odds( spread_x = 0 # Top left spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1368,7 +1391,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1394,7 +1417,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1405,6 +1428,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/lacrosse-scoreboard/test_element_text_colors.py b/plugins/lacrosse-scoreboard/test_element_text_colors.py index d103e1c5..f359b1f5 100644 --- a/plugins/lacrosse-scoreboard/test_element_text_colors.py +++ b/plugins/lacrosse-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 6d19d76c..36662817 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -1028,6 +1028,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index fa5cde6a..ab84ca83 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -124,6 +124,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -132,6 +135,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") except Exception as e: @@ -400,6 +404,7 @@ def _calculate_max_logo_dimensions( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", @@ -1102,6 +1107,23 @@ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: fill=self._element_color(bottom_color) ) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning.""" try: @@ -1152,7 +1174,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None # widest time string the centre can hold, measured in the font the # renderer will actually use, so this tracks font changes instead # of hard-coding a width. - font = self.fonts["detail"] + font = self.fonts.get("odds") or self.fonts["detail"] time_font = self.fonts.get("time", font) centre_reserve = draw.textlength("12:00 PM", font=time_font) side_budget = max(0.0, (self.display_width - centre_reserve) / 2) @@ -1171,7 +1193,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color()) # Show over/under on opposite side over_under = odds.get("over_under") @@ -1199,7 +1221,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_x = 0 + odds_x_offset ou_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color()) except Exception as e: self.logger.error(f"Error drawing odds: {e}") diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 764c4787..6de449f9 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.18.1", + "version": "1.19.0", "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.19.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "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.18.1", "released": "2026-09-02", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 288be22f..7f5515c4 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -1373,6 +1373,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1381,6 +1384,7 @@ def _load_fonts(self): fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6, element_key='detail_text', default_font='4x6-font.ttf') + fonts["odds"] = self._load_custom_font_from_element_config(odds_config, default_size=6, element_key='odds_text', default_font='4x6-font.ttf') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1412,6 +1416,23 @@ def _load_fonts(self): # narrower FACE rather than let a grown score crowd out the logos. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1476,7 +1497,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1484,7 +1505,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width # Top right spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1494,7 +1515,7 @@ def _draw_dynamic_odds( spread_x = 0 # Top left spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1504,7 +1525,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1530,7 +1551,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1541,6 +1562,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/nrl-scoreboard/test_element_text_colors.py b/plugins/nrl-scoreboard/test_element_text_colors.py index c06e4572..cc100702 100644 --- a/plugins/nrl-scoreboard/test_element_text_colors.py +++ b/plugins/nrl-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)}, diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index de584201..4c52e585 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -5978,6 +5978,45 @@ ], "additionalProperties": false }, + "odds_text": { + "type": "object", + "title": "Odds Text", + "description": "Customize the betting line and over/under text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "4x6-font.ttf", + "description": "Font family for odds text. Defaults to the same 4x6 font the detail text uses; press_start is chunkier and easier to read on a large panel, but roughly twice as wide." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for odds text. Sizes snap to the font's pixel grid to stay crisp: 4x6-font.ttf snaps to 7, 14, 21; press_start to 8, 16." + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the odds text.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, "rank_text": { "type": "object", "title": "Rankings", diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index d517506e..4bf44690 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -124,6 +124,9 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -132,6 +135,7 @@ def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: fonts["team"] = self._load_custom_font(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6-font.ttf', element_key='detail_text') + fonts["odds"] = self._load_custom_font(odds_config, default_size=6, default_font='4x6-font.ttf', element_key='odds_text') fonts["rank"] = self._load_custom_font(rank_config, default_size=10, element_key='rank_text') self.logger.debug("Successfully loaded fonts from config") except Exception as e: @@ -400,6 +404,7 @@ def _calculate_max_logo_dimensions( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", @@ -1102,6 +1107,23 @@ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: fill=self._element_color(bottom_color) ) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning.""" try: @@ -1152,7 +1174,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None # widest time string the centre can hold, measured in the font the # renderer will actually use, so this tracks font changes instead # of hard-coding a width. - font = self.fonts["detail"] + font = self.fonts.get("odds") or self.fonts["detail"] time_font = self.fonts.get("time", font) centre_reserve = draw.textlength("12:00 PM", font=time_font) side_budget = max(0.0, (self.display_width - centre_reserve) / 2) @@ -1171,7 +1193,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_x = 0 + odds_x_offset spread_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color()) # Show over/under on opposite side over_under = odds.get("over_under") @@ -1199,7 +1221,7 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_x = 0 + odds_x_offset ou_y = 0 + odds_y_offset - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color()) except Exception as e: self.logger.error(f"Error drawing odds: {e}") diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 79171ddc..1ac4f068 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.21.1", + "version": "2.22.0", "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.22.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "Odds get their own font, size and colour. The betting line and over/under borrowed the detail_text settings, so making them bigger on a large panel also enlarged the team records and the date row, and their green was hard-coded in the drawing call where no setting could reach it. A new customization.odds_text block controls the odds alone, in both the full-screen scorebug and the scroll/Vegas card. Nothing changes unless you set it: the defaults are exactly what was rendered before -- the same 4x6 font at the same size, and the same green -- and a config saved before this setting existed falls back to detail_text, so all 176 safety-harness renders are byte-identical to the previous version. Sizes still snap to the font's pixel grid to stay crisp, so 4x6-font.ttf gives 7, 14 or 21 and press_start gives 8 or 16.", + "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.21.1", "released": "2026-09-02", @@ -279,7 +286,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" }, { @@ -309,7 +316,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/sports.py b/plugins/soccer-scoreboard/sports.py index 9f1f3799..782b767c 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -1360,6 +1360,9 @@ def _load_fonts(self): team_config = customization.get('team_name', {}) status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) + # Falls back to detail_text so a config written before this + # setting existed keeps rendering odds exactly as it did. + odds_config = customization.get('odds_text') or detail_config rank_config = customization.get('rank_text', {}) try: @@ -1368,6 +1371,7 @@ def _load_fonts(self): fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8, element_key='team_name') fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6, element_key='status_text') fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6, element_key='detail_text', default_font='4x6-font.ttf') + fonts["odds"] = self._load_custom_font_from_element_config(odds_config, default_size=6, element_key='odds_text', default_font='4x6-font.ttf') fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10, element_key='rank_text') self.logger.info("Successfully loaded fonts from config") except Exception as e: @@ -1400,6 +1404,23 @@ def _load_fonts(self): # narrower FACE rather than let a grown score crowd out the logos. return self._fit_score_font(self._scale_headline_fonts(fonts)) + def _odds_color(self) -> Tuple[int, int, int]: + """Colour for the odds text; the green it always drew unless configured. + + Guarded with getattr because not every class that reaches + _draw_dynamic_odds carries the element-colour helper -- the plugins' + own test harnesses build minimal manager objects, and a bare + AttributeError here is swallowed by the surrounding except, which + drops the odds off the card instead of failing loudly. + """ + getter = getattr(self, "_element_color", None) + if getter is None: + return (0, 255, 0) + try: + return getter("odds_text", (0, 255, 0)) + except Exception: + return (0, 255, 0) + def _draw_dynamic_odds( self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int ) -> None: @@ -1464,7 +1485,7 @@ def _draw_dynamic_odds( # Show the negative spread on the appropriate side if favored_spread is not None: spread_text = str(favored_spread) - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] if favored_side == "home": # Home team is favored, show spread on right side @@ -1472,7 +1493,7 @@ def _draw_dynamic_odds( spread_x = width - spread_width # Top right spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing home spread '{spread_text}' on right side" @@ -1482,7 +1503,7 @@ def _draw_dynamic_odds( spread_x = 0 # Top left spread_y = 0 self._draw_text_with_outline( - draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + draw, spread_text, (spread_x, spread_y), font, fill=self._odds_color() ) self.logger.debug( f"Showing away spread '{spread_text}' on left side" @@ -1492,7 +1513,7 @@ def _draw_dynamic_odds( over_under = odds.get("over_under") if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" - font = self.fonts["detail"] # Use detail font for odds + font = self.fonts.get("odds") or self.fonts["detail"] ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": @@ -1518,7 +1539,7 @@ def _draw_dynamic_odds( ) self._draw_text_with_outline( - draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + draw, ou_text, (ou_x, ou_y), font, fill=self._odds_color() ) except Exception as e: @@ -1529,6 +1550,7 @@ def _draw_dynamic_odds( #: resolving the colour from the face keeps the two in step by #: construction, rather than by every draw site remembering to agree. _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "odds": "odds_text", "score": "score_text", "time": "period_text", "team": "team_name", diff --git a/plugins/soccer-scoreboard/test_element_text_colors.py b/plugins/soccer-scoreboard/test_element_text_colors.py index 6c24d007..7e8a2508 100644 --- a/plugins/soccer-scoreboard/test_element_text_colors.py +++ b/plugins/soccer-scoreboard/test_element_text_colors.py @@ -126,9 +126,18 @@ def draw_with(config, font_key, text="12:00", fill=None): if "text_color" in v.get("properties", {})} defaults = draw_with({"customization": materialised}, "time") check("schema defaults render identically to no config", defaults == plain) + # odds_text is the one deliberate exception. The betting line and + # over/under have always drawn green -- it was hard-coded in the draw call + # before the setting existed -- so advertising white would both lie about + # the default and change what is rendered the moment the store + # materialises it. Green here IS "unset", which is what the check above + # actually cares about. check("every advertised text_color default is white", all(tuple(v["text_color"]) == (255, 255, 255) - for v in materialised.values())) + for k, v in materialised.items() if k != "odds_text")) + check("odds_text advertises the green it has always drawn", + tuple(materialised.get("odds_text", {}).get("text_color", (0, 255, 0))) + == (0, 255, 0)) # -- 2. a configured colour reaches that element's face ------------------- cfg = {"customization": {"period_text": {"text_color": list(CYAN)},