From c82dbd16051191ab1f25979954df47e596484222 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:41:29 +0000 Subject: [PATCH 1/3] feat(scoreboards): color a recent game's score by how the favorite team did In scroll and Vegas modes a series against the same opponent scrolls past as several near-identical cards -- same two logos, only the digits change -- and working out which of them your team won means reading the score and remembering which side you are. That is the whole problem this fixes. Adds customization.favorite_result_colors to the eight team scoreboards that show a numeric final score (AFL, baseball, basketball, football, hockey, lacrosse, NRL, soccer): with it enabled, a finished game's score is drawn green when the favorite team won and red when it lost, with all three colors configurable. Off by default, so nothing changes until a user opts in. The colour is resolved in both render paths, which are independent copies: SportsCore for the one-game-at-a-time switch view and GameRenderer for the scroll/Vegas cards (which is also what get_vegas_content renders through). Only finished games are tinted; live and upcoming cards are untouched. A game needs exactly one favorite team -- neither side, or both, keeps the normal color, since a favorite-vs-favorite game has no losing side worth flagging in red. Games now carry their league manager's resolved favorites so the scroll renderer, which otherwise only sees the game dict and the raw config, can match dynamic groups such as AP_TOP_25; the raw config is read as well so an edit applies before the next data refresh. Matching accepts the ESPN id as well as the abbreviation, which is how NRL keys its favorites. NRL and Soccer declared their customization block at the schema root rather than inside properties, so the web UI never rendered it and setting it tripped the root's additionalProperties. Moved it where it belongs, which is also what makes the new setting reachable there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RRLLpvsvXgy1pskvaNVJWx --- plugins.json | 16 +- plugins/afl-scoreboard/README.md | 32 + plugins/afl-scoreboard/config_schema.json | 68 +- plugins/afl-scoreboard/game_renderer.py | 138 ++- plugins/afl-scoreboard/manifest.json | 8 +- plugins/afl-scoreboard/sports.py | 102 ++- plugins/baseball-scoreboard/README.md | 32 + .../baseball-scoreboard/config_schema.json | 70 +- plugins/baseball-scoreboard/game_renderer.py | 137 ++- plugins/baseball-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/sports.py | 102 ++- .../test_favorite_result_colors.py | 243 ++++++ plugins/basketball-scoreboard/README.md | 32 + .../basketball-scoreboard/config_schema.json | 67 +- .../basketball-scoreboard/game_renderer.py | 138 ++- plugins/basketball-scoreboard/manifest.json | 8 +- plugins/basketball-scoreboard/sports.py | 102 ++- plugins/football-scoreboard/README.md | 32 + .../football-scoreboard/config_schema.json | 67 +- plugins/football-scoreboard/game_renderer.py | 141 +++- plugins/football-scoreboard/manifest.json | 8 +- plugins/football-scoreboard/sports.py | 102 ++- plugins/hockey-scoreboard/README.md | 32 + plugins/hockey-scoreboard/config_schema.json | 67 +- plugins/hockey-scoreboard/game_renderer.py | 138 ++- plugins/hockey-scoreboard/manifest.json | 8 +- plugins/hockey-scoreboard/sports.py | 102 ++- plugins/lacrosse-scoreboard/README.md | 32 + .../lacrosse-scoreboard/config_schema.json | 68 +- plugins/lacrosse-scoreboard/game_renderer.py | 138 ++- plugins/lacrosse-scoreboard/manifest.json | 8 +- plugins/lacrosse-scoreboard/sports.py | 102 ++- plugins/nrl-scoreboard/README.md | 31 + plugins/nrl-scoreboard/config_schema.json | 797 ++++++++++-------- plugins/nrl-scoreboard/game_renderer.py | 138 ++- plugins/nrl-scoreboard/manifest.json | 8 +- plugins/nrl-scoreboard/sports.py | 102 ++- plugins/nrl-scoreboard/test_nrl_plugin.py | 7 +- plugins/soccer-scoreboard/README.md | 32 + plugins/soccer-scoreboard/config_schema.json | 737 ++++++++-------- plugins/soccer-scoreboard/game_renderer.py | 138 ++- plugins/soccer-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/sports.py | 102 ++- 43 files changed, 3706 insertions(+), 742 deletions(-) create mode 100644 plugins/baseball-scoreboard/test_favorite_result_colors.py diff --git a/plugins.json b/plugins.json index e08d8253..2db494f0 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.22.2" + "latest_version": "1.23.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.10.3" + "latest_version": "1.11.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.11.2" + "latest_version": "2.12.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.3", + "latest_version": "1.8.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.3", + "latest_version": "1.8.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.6.2" + "latest_version": "2.7.0" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.3.2", + "latest_version": "1.4.0", "last_updated": "2026-08-05" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.2" + "latest_version": "1.4.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/README.md b/plugins/afl-scoreboard/README.md index 5f398399..3743e823 100644 --- a/plugins/afl-scoreboard/README.md +++ b/plugins/afl-scoreboard/README.md @@ -25,6 +25,7 @@ Football League)** games with real-time scores and game status. - **Switch or Scroll**: Show one game at a time, or scroll all games horizontally - **Dynamic Duration & Live Priority**: Spend more time on live games; let live games interrupt the rotation - **Background Data Fetching**: Efficient API calls without blocking the display +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Display Modes @@ -149,6 +150,37 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` upcoming game) for the core plugin safety harness (`LEDMatrix/scripts/check_plugin.py`). +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 02e0ccc8..a053e8cd 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -900,6 +900,71 @@ "records" ], "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false } }, "x-propertyOrder": [ @@ -909,7 +974,8 @@ "status_text", "detail_text", "rank_text", - "layout" + "layout", + "favorite_result_colors" ], "additionalProperties": false } diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index 76eb7ad1..3ad3b255 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -303,6 +303,139 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -373,7 +506,10 @@ def render_game_card( # Draw scores (centered) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) # Draw period/status based on game type if game_type == "live": diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index bc3132fe..2fde071a 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.3.2", + "version": "1.4.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,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.4.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.2", "released": "2026-08-05", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 95365559..6ad3c058 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -476,6 +476,97 @@ 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}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1066,6 +1157,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -2099,7 +2195,11 @@ def format_score(score): # date fits on the bottom line without colliding with the score. score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 1eb774ab..22275ae2 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -23,6 +23,7 @@ A plugin for LEDMatrix that displays live, recent, and upcoming baseball games a - **Upcoming Games**: Scheduled games with start times - **Favorite Teams**: Prioritize games involving your favorite teams - **Background Data Fetching**: Efficient API calls without blocking display +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Configuration @@ -411,6 +412,37 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` (default `plugin-repos/`) and restart the display service. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Game times look like UTC** (a 6:45pm Central first pitch showing as diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 051a89dd..f3581f7c 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -2056,7 +2056,72 @@ } }, "additionalProperties": false - } + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false + } }, "x-propertyOrder": [ "score_text", @@ -2071,7 +2136,8 @@ "count", "at_bat_info", "player_card", - "traditional_scoreboard" + "traditional_scoreboard", + "favorite_result_colors" ], "additionalProperties": false } diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 402441f0..cd025936 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -226,6 +226,139 @@ def set_rankings_cache(self, rankings: Dict[str, int]) -> None: """Set the team rankings cache for display.""" self._team_rankings_cache = rankings + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card(self, game: Dict, game_type: str) -> Image.Image: """ Render a game card as a PIL Image. @@ -433,7 +566,9 @@ def _render_recent_game(self, game: Dict) -> Image.Image: score_width = draw.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 score_y = self.display_height - 14 - self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'], fill=(255, 200, 0)) + self._draw_text_with_outline(draw, score_text, (score_x, score_y), + self.fonts['score'], + fill=self._recent_score_color(game, (255, 200, 0))) # Records at bottom corners self._draw_records(draw, game) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 66998a04..c8fe482b 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.22.2", + "version": "1.23.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.23.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.22.2", "released": "2026-08-05", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 2fb7ac10..f6c1b0e8 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -411,6 +411,97 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -957,6 +1048,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1963,7 +2059,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') # Centered vertically, same as live games self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # Game date (Bottom of display, one line above bottom edge, centered) with layout offsets diff --git a/plugins/baseball-scoreboard/test_favorite_result_colors.py b/plugins/baseball-scoreboard/test_favorite_result_colors.py new file mode 100644 index 00000000..812ca155 --- /dev/null +++ b/plugins/baseball-scoreboard/test_favorite_result_colors.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Tests for customization.favorite_result_colors -- tinting a finished game's +score green when a favorite team won and red when it lost. + +Covers both render paths, because they resolve the color independently: + * SportsCore._recent_score_color (switch mode, sports.py) + * GameRenderer._recent_score_color (scroll/Vegas cards, game_renderer.py) + +And the cases that must NOT be tinted: the feature off (the default), no +favorites configured, a game between two non-favorites, and a game between +two favorites -- there is no losing side worth flagging red in that one. + +Run: /bin/python plugins/baseball-scoreboard/test_favorite_result_colors.py +""" + +import logging +import os +import sys + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from game_renderer import GameRenderer # noqa: E402 +from sports import SportsCore # noqa: E402 + +WHITE = (255, 255, 255) +GOLD = (255, 200, 0) +GREEN = (0, 255, 0) +RED = (255, 0, 0) + +ON = {"customization": {"favorite_result_colors": {"enabled": True}}} + + +def _game(home, away, home_score, away_score, favorites=None, league="mlb"): + game = { + "id": f"{away}@{home}", + "league": league, + "home_abbr": home, + "away_abbr": away, + "home_id": f"id-{home}", + "away_id": f"id-{away}", + "home_score": home_score, + "away_score": away_score, + "is_final": True, + } + if favorites is not None: + game["favorite_teams"] = favorites + return game + + +class _ConcreteCore(SportsCore): + """Concrete SportsCore so it can be instantiated without the manager stack.""" + + def _extract_game_details(self, game): # abstract in SportsCore + return None + + def _fetch_data(self): # abstract in SportsCore + return None + + +def _core(favorite_teams, config=None): + """A SportsCore with just the attributes the color helpers touch.""" + core = object.__new__(_ConcreteCore) + core.logger = logging.getLogger("test_favorite_result_colors") + core.config = config if config is not None else dict(ON) + core.favorite_teams = favorite_teams + return core + + +def _renderer(config): + renderer = object.__new__(GameRenderer) + renderer.logger = logging.getLogger("test_favorite_result_colors") + renderer.config = config + return renderer + + +def _check(label, actual, expected): + assert actual == expected, f"{label}: expected {expected}, got {actual}" + print(f" PASS {label}") + + +def test_switch_mode_colors(): + print("\n[switch mode] SportsCore._recent_score_color") + core = _core(["ATL"]) + + _check("favorite won at home", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), GREEN) + _check("favorite won away", + core._recent_score_color(_game("NYM", "ATL", "2", "5"), WHITE), GREEN) + _check("favorite lost at home", + core._recent_score_color(_game("ATL", "NYM", "1", "7"), WHITE), RED) + _check("favorite lost away", + core._recent_score_color(_game("NYM", "ATL", "7", "1"), WHITE), RED) + _check("tie", + core._recent_score_color(_game("ATL", "NYM", "3", "3"), WHITE), (255, 200, 0)) + _check("neither team is a favorite", + core._recent_score_color(_game("NYM", "PHI", "4", "1"), WHITE), WHITE) + + +def test_disabled_by_default(): + print("\n[default off]") + core = _core(["ATL"], config={}) + _check("no customization block", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + + core = _core(["ATL"], config={"customization": {"favorite_result_colors": {}}}) + _check("block present, enabled omitted", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + + +def test_no_single_team_to_root_for(): + print("\n[no single favorite]") + _check("no favorites configured", + _core([])._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + _check("both teams are favorites", + _core(["ATL", "NYM"])._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), + WHITE) + + +def test_unusable_scores(): + print("\n[unusable scores]") + core = _core(["ATL"]) + _check("postponed, no score", + core._recent_score_color(_game("ATL", "NYM", "", ""), WHITE), WHITE) + _check("non-numeric score", + core._recent_score_color(_game("ATL", "NYM", "-", "-"), WHITE), WHITE) + + +def test_custom_colors(): + print("\n[custom colors]") + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": [0, 128, 255], + "loss_color": [128, 0, 128], + }}}) + _check("configured win color", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), (0, 128, 255)) + _check("configured loss color", + core._recent_score_color(_game("ATL", "NYM", "2", "5"), WHITE), (128, 0, 128)) + + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": [999, -5, "12"], + }}}) + _check("out-of-range channels are clamped", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), (255, 0, 12)) + + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": "not a color", + }}}) + _check("garbage color falls back to the built-in win color", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), GREEN) + + +def test_scroll_card_colors(): + print("\n[scroll/Vegas] GameRenderer._recent_score_color") + renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "mlb": {"favorite_teams": ["ATL"]}}) + + # The gold default the recent card already used is what an untinted card + # must keep, so an existing install sees no change until it opts in. + off = _renderer({"mlb": {"favorite_teams": ["ATL"]}}) + _check("feature off keeps the card's own default", + off._recent_score_color(_game("ATL", "NYM", "5", "2"), GOLD), GOLD) + + _check("favorite won", renderer._recent_score_color( + _game("ATL", "NYM", "5", "2"), GOLD), GREEN) + _check("favorite lost", renderer._recent_score_color( + _game("ATL", "NYM", "2", "5"), GOLD), RED) + _check("other matchup untouched", renderer._recent_score_color( + _game("NYM", "PHI", "4", "1"), GOLD), GOLD) + + # Only finished games are tinted; a live card keeps its normal color even + # when the favorite happens to be ahead. + _check("live game not tinted", renderer._score_color_for( + _game("ATL", "NYM", "5", "2"), "live"), WHITE) + _check("upcoming game not tinted", renderer._score_color_for( + _game("ATL", "NYM", "0", "0"), "upcoming"), WHITE) + _check("recent game tinted", renderer._score_color_for( + _game("ATL", "NYM", "5", "2"), "recent"), GREEN) + + +def test_scroll_card_favorite_sources(): + print("\n[scroll/Vegas] where favorites come from") + + # Soccer-style config, where favorites live under a per-league section the + # renderer cannot find by name -- the game's stamped list carries them. + stamped = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}}) + _check("favorites stamped on the game", stamped._recent_score_color( + _game("ATL", "NYM", "5", "2", favorites=["ATL"]), GOLD), GREEN) + + # A league section the renderer can find, with nothing stamped (hand-built + # game dicts, and config edits that beat the next data refresh). + from_config = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "mlb": {"favorite_teams": ["ATL"]}}) + _check("favorites read from config", from_config._recent_score_color( + _game("ATL", "NYM", "5", "2"), GOLD), GREEN) + + # NRL keys favorites by ESPN id because its abbreviations collide. + by_id = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "nrl": {"favorite_teams": ["id-ATL"]}}) + _check("favorites matched by id", by_id._recent_score_color( + _game("ATL", "NYM", "5", "2", league="nrl"), GOLD), GREEN) + + +def test_scroll_card_nested_payload(): + print("\n[scroll/Vegas] nested payload (hockey/lacrosse shape)") + renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "nhl": {"favorite_teams": ["TB"]}}) + game = { + "league": "nhl", + "is_final": True, + "home_team": {"abbrev": "TB", "score": "4"}, + "away_team": {"abbrev": "BOS", "score": "1"}, + } + _check("nested favorite won", renderer._recent_score_color(game, WHITE), GREEN) + + game["home_team"]["score"] = "0" + _check("nested favorite lost", renderer._recent_score_color(game, WHITE), RED) + + +def main(): + tests = [ + test_switch_mode_colors, + test_disabled_by_default, + test_no_single_team_to_root_for, + test_unusable_scores, + test_custom_colors, + test_scroll_card_colors, + test_scroll_card_favorite_sources, + test_scroll_card_nested_payload, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} test groups passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/README.md b/plugins/basketball-scoreboard/README.md index ac691a58..a973b8d9 100644 --- a/plugins/basketball-scoreboard/README.md +++ b/plugins/basketball-scoreboard/README.md @@ -27,6 +27,7 @@ A plugin for LEDMatrix that displays live, recent, and upcoming basketball games - **Per-League Configuration**: Independent settings for each league - **Flexible Display Options**: Show records, rankings, and betting odds - **Advanced Filtering**: Control which teams and games are displayed +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Configuration @@ -385,6 +386,37 @@ shorten). Leave it at `0` to display every live game for `live_game_duration`. } ``` +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index d437602e..5b813de6 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -1977,9 +1977,74 @@ }, "x-propertyOrder": ["home_logo", "away_logo", "score", "status", "record", "ranking", "odds"], "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false } }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout"], + "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], "additionalProperties": false } }, diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index a3c66b0b..e402d8ba 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -281,6 +281,139 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -357,7 +490,10 @@ def render_game_card( score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) # Draw period/status based on game type if game_type == "live": diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 294f2164..626baf64 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.10.3", + "version": "1.11.0", "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", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.11.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.10.3", "released": "2026-08-05", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 59293014..ab0c0796 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -313,6 +313,97 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], self.logger.error(f"Error loading default font: {e}") return ImageFont.load_default() + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1119,6 +1210,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } # --- Tournament metadata extraction (March Madness) --- @@ -2230,7 +2326,11 @@ def format_score(score): score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = display_height - 14 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/football-scoreboard/README.md b/plugins/football-scoreboard/README.md index 65d53231..dfd6bb0c 100644 --- a/plugins/football-scoreboard/README.md +++ b/plugins/football-scoreboard/README.md @@ -34,6 +34,7 @@ Recent Game (NCAA FB): - **Upcoming Games**: Scheduled games with start times and odds - **Dynamic Team Resolution**: Support for `AP_TOP_25`, `AP_TOP_10`, `AP_TOP_5` automatic team selection - **Production-Ready**: Real ESPN API integration with caching and error handling +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ### Professional Display - **Team Logos**: Professional team logos with automatic download fallback @@ -399,6 +400,37 @@ Configure per league (under the `nfl` / `ncaa_fb` config sections): - `celebrate_opponent_scores` (also celebrate the opponent, default `false`; when no favorite teams are configured, any team's score celebrates) +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## 🏷️ Team Abbreviations ### NFL Teams diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 0eddcb8e..7686097d 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -1207,9 +1207,74 @@ }, "x-propertyOrder": ["home_logo", "away_logo", "score", "status_text", "date", "time", "down_distance", "timeouts", "possession", "records", "odds"], "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false } }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout"], + "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], "additionalProperties": false } }, diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index d370dbc1..b712c7bf 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -372,6 +372,139 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -441,7 +574,10 @@ def render_game_card( score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) # Draw period/status based on game type if game_type == "live": @@ -655,7 +791,8 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], score_region = self._region_for(regs.score_area, 'score') score_fit = self._fit_element('score', score_text, score_region, ADAPTIVE_LADDER_HEADLINE) - self._draw_fit_outline(draw_overlay, score_fit, score_region) + self._draw_fit_outline(draw_overlay, score_fit, score_region, + fill=self._score_color_for(game, game_type)) if game_type == "live": self._draw_live_status_adaptive(draw_overlay, game, regs) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 9254aa87..4002dab8 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.11.2", + "version": "2.12.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.12.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.11.2", "released": "2026-08-05", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index c5f2852d..17e3cd36 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -386,6 +386,97 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -917,6 +1008,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1931,7 +2027,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') # Centered vertically, same as live games self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # Game date (Bottom of display, one line above bottom edge, centered) with layout offsets diff --git a/plugins/hockey-scoreboard/README.md b/plugins/hockey-scoreboard/README.md index c92e4f79..2d5653a0 100644 --- a/plugins/hockey-scoreboard/README.md +++ b/plugins/hockey-scoreboard/README.md @@ -39,6 +39,7 @@ Upcoming Game: - **Team Logos**: Display team logos when available - **Background Data Fetching**: Efficient API calls with caching - **Font Customization**: Override fonts via Web UI +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Requirements @@ -370,6 +371,37 @@ Add your favorite team abbreviations to the `favorite_teams` object for each lea Make sure `enabled: true` in the configuration and the plugin is activated in the rotation. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting **No games showing:** diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index d36ad807..4b2110f9 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -1624,9 +1624,74 @@ }, "x-propertyOrder": ["home_logo", "away_logo", "score", "status_text", "date", "time", "records"], "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false } }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout"], + "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], "additionalProperties": false } }, diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index efbcbf60..4ebfacd4 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -316,6 +316,139 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: return normalized + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -387,7 +520,10 @@ def render_game_card( score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) elif game_type == "upcoming": # Draw "VS" for upcoming games vs_text = "VS" diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index cab5a1f8..a91f069d 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.7.3", + "version": "1.8.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,12 @@ } ], "versions": [ + { + "version": "1.8.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.3", "released": "2026-08-05", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 10925cdb..2b76d57f 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -337,6 +337,97 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -837,6 +928,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1756,7 +1852,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (self.display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = self.display_height - 14 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md index a7fd3511..8c747561 100644 --- a/plugins/lacrosse-scoreboard/README.md +++ b/plugins/lacrosse-scoreboard/README.md @@ -23,6 +23,7 @@ Live, recent, and upcoming NCAA Men's and Women's Lacrosse games on your LEDMatr - **Poll rank badges** — `#1`, `#2` overlays on team names, updated hourly from ESPN's public rankings feed - **Element customization** — toggle records, rankings, odds, shot totals; override layout offsets for logos, score, and status text - **Configurable durations, update intervals, and game counts** per league +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Requirements @@ -250,6 +251,37 @@ Scores and schedules come from ESPN's public site API: Team logos are fetched from `https://a.espncdn.com/i/teamlogos/ncaa/500/{team_id}.png` and cached locally under `assets/sports/ncaa_logos/`. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 15dc6317..479d230c 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -1259,6 +1259,71 @@ "records" ], "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false } }, "x-propertyOrder": [ @@ -1268,7 +1333,8 @@ "status_text", "detail_text", "rank_text", - "layout" + "layout", + "favorite_result_colors" ], "additionalProperties": false } diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 88834ae1..94a49b25 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -325,6 +325,139 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: return normalized + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -396,7 +529,10 @@ def render_game_card( score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) # Draw period/status based on game type if game_type == "live": diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 0853526b..76260a2b 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.7.3", + "version": "1.8.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,12 @@ } ], "versions": [ + { + "version": "1.8.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.3", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 0c75aa31..6b25480c 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -338,6 +338,97 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -838,6 +929,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1755,7 +1851,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (self.display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = self.display_height - 14 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/nrl-scoreboard/README.md b/plugins/nrl-scoreboard/README.md index 79daaf9f..e1e9077b 100644 --- a/plugins/nrl-scoreboard/README.md +++ b/plugins/nrl-scoreboard/README.md @@ -108,6 +108,37 @@ options (see `config_schema.json` for the full list, types, and defaults): timezone; if that isn't set, the host system's timezone is used, and only if neither is available do times fall back to UTC. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## License See `LICENSE`. diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index c081eb25..386a6a7d 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -519,8 +519,436 @@ } }, "additionalProperties": false + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "description": "Font settings for the game score display", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use for scores", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "period_text": { + "type": "object", + "title": "Period/Clock", + "description": "Font settings for period, clock, and time remaining text", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g., 'Next Game', 'Final')", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Details/Odds", + "description": "Font settings for odds and other detail information", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "rank_text": { + "type": "object", + "title": "Rankings", + "description": "Font settings for ranking displays", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status/Period Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } + }, + "additionalProperties": false + }, + "date": { + "type": "object", + "title": "Game Date", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "title": "Game Time", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } + }, + "additionalProperties": false + }, + "records": { + "type": "object", + "title": "Records/Rankings", + "properties": { + "away_x_offset": { + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], + "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout", + "favorite_result_colors" + ], + "additionalProperties": false } - }, +}, "additionalProperties": false, "required": [ "enabled" @@ -557,368 +985,7 @@ "timezone", "dynamic_duration", "mode_durations", - "background_service" - ], - "customization": { - "type": "object", - "title": "Display Customization", - "description": "Customize fonts for different text elements on the scoreboard", - "properties": { - "score_text": { - "type": "object", - "title": "Game Score", - "description": "Font settings for the game score display", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use for scores", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" - }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } - }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "period_text": { - "type": "object", - "title": "Period/Clock", - "description": "Font settings for period, clock, and time remaining text", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" - }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } - }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "team_name": { - "type": "object", - "title": "Team Names", - "description": "Font settings for team name abbreviations", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" - }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } - }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status Messages", - "description": "Font settings for status text (e.g., 'Next Game', 'Final')", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" - }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } - }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "detail_text": { - "type": "object", - "title": "Details/Odds", - "description": "Font settings for odds and other detail information", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" - }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } - }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "rank_text": { - "type": "object", - "title": "Rankings", - "description": "Font settings for ranking displays", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" - }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } - }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "layout": { - "type": "object", - "title": "Layout Positioning", - "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", - "properties": { - "home_logo": { - "type": "object", - "title": "Home Team Logo", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "away_logo": { - "type": "object", - "title": "Away Team Logo", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } - }, - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status/Period Text", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from top (default: 0)" - } - }, - "additionalProperties": false - }, - "date": { - "type": "object", - "title": "Game Date", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "time": { - "type": "object", - "title": "Game Time", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from date position (default: 0)" - } - }, - "additionalProperties": false - }, - "records": { - "type": "object", - "title": "Records/Rankings", - "properties": { - "away_x_offset": { - "type": "integer", - "default": 0, - "description": "Away team record horizontal offset from left (default: 0)" - }, - "home_x_offset": { - "type": "integer", - "default": 0, - "description": "Home team record horizontal offset from right (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from bottom (default: 0)" - } - }, - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "home_logo", - "away_logo", - "score", - "status_text", - "date", - "time", - "records" - ], - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "score_text", - "period_text", - "team_name", - "status_text", - "detail_text", - "rank_text", - "layout" - ], - "additionalProperties": false - } + "background_service", + "customization" + ] } \ No newline at end of file diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index 3459c790..fa42942f 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -278,6 +278,139 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -348,7 +481,10 @@ def render_game_card( # Draw scores (centered) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) # Draw period/status based on game type if game_type == "live": diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index ccd5f6d0..0d3a046f 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.3.2", + "version": "1.4.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,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.4.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.2", "released": "2026-08-05", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 1bd22953..68144f1c 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -495,6 +495,97 @@ 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}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1083,6 +1174,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -2112,7 +2208,11 @@ def format_score(score): # date fits on the bottom line without colliding with the score. score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/nrl-scoreboard/test_nrl_plugin.py b/plugins/nrl-scoreboard/test_nrl_plugin.py index 184a028b..14808d07 100644 --- a/plugins/nrl-scoreboard/test_nrl_plugin.py +++ b/plugins/nrl-scoreboard/test_nrl_plugin.py @@ -62,8 +62,11 @@ def test_schema_parses_and_core_fields(self): "background_service", ): self.assertIn(key, props, f"missing schema field: {key}") - # customization block is a root-level sibling (parity with soccer) - self.assertIn("customization", s) + # The customization block used to sit at the schema root, a sibling of + # `properties` rather than one of them, so the web UI never rendered it + # and a config that set it tripped the root's additionalProperties. + self.assertNotIn("customization", s) + self.assertIn("customization", props) # display_modes toggles use live/recent/upcoming (not nrl_-prefixed) dm = props["display_modes"]["properties"] for key in ("live", "recent", "upcoming", diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index 4259310c..5a9c37ca 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -23,6 +23,7 @@ A plugin for LEDMatrix that displays live, recent, and upcoming soccer games acr - **Upcoming Games**: Scheduled games with start times - **Favorite Teams**: Prioritize games involving your favorite teams - **Background Data Fetching**: Efficient API calls without blocking display +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Configuration @@ -320,6 +321,37 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` (default `plugin-repos/`) and restart the display service. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 1f0e95d4..b36b9676 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -4563,370 +4563,436 @@ ], "additionalProperties": false } - } - }, - "customization": { - "type": "object", - "title": "Display Customization", - "description": "Customize fonts for different text elements on the scoreboard", - "properties": { - "score_text": { - "type": "object", - "title": "Game Score", - "description": "Font settings for the game score display", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use for scores", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "description": "Font settings for the game score display", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use for scores", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "period_text": { - "type": "object", - "title": "Period/Clock", - "description": "Font settings for period, clock, and time remaining text", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "period_text": { + "type": "object", + "title": "Period/Clock", + "description": "Font settings for period, clock, and time remaining text", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "team_name": { - "type": "object", - "title": "Team Names", - "description": "Font settings for team name abbreviations", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status Messages", - "description": "Font settings for status text (e.g., 'Next Game', 'Final')", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g., 'Next Game', 'Final')", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "detail_text": { - "type": "object", - "title": "Details/Odds", - "description": "Font settings for odds and other detail information", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" + "detail_text": { + "type": "object", + "title": "Details/Odds", + "description": "Font settings for odds and other detail information", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "rank_text": { - "type": "object", - "title": "Rankings", - "description": "Font settings for ranking displays", - "properties": { - "font": { - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "rank_text": { + "type": "object", + "title": "Rankings", + "description": "Font settings for ranking displays", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "layout": { - "type": "object", - "title": "Layout Positioning", - "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", - "properties": { - "home_logo": { - "type": "object", - "title": "Home Team Logo", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "away_logo": { - "type": "object", - "title": "Away Team Logo", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status/Period Text", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "status_text": { + "type": "object", + "title": "Status/Period Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from top (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "date": { - "type": "object", - "title": "Game Date", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "date": { + "type": "object", + "title": "Game Date", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "time": { - "type": "object", - "title": "Game Time", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "time": { + "type": "object", + "title": "Game Time", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from date position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false + "records": { + "type": "object", + "title": "Records/Rankings", + "properties": { + "away_x_offset": { + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + } }, - "records": { - "type": "object", - "title": "Records/Rankings", - "properties": { - "away_x_offset": { + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], + "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Away team record horizontal offset from left (default: 0)" + "minimum": 0, + "maximum": 255 }, - "home_x_offset": { + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Home team record horizontal offset from right (default: 0)" + "minimum": 0, + "maximum": 255 }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from bottom (default: 0)" - } + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true }, - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "home_logo", - "away_logo", - "score", - "status_text", - "date", - "time", - "records" - ], - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "score_text", - "period_text", - "team_name", - "status_text", - "detail_text", - "rank_text", - "layout" - ], - "additionalProperties": false - }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [255, 200, 0], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout", + "favorite_result_colors" + ], + "additionalProperties": false + } +}, "additionalProperties": false, "required": [ "enabled" @@ -4949,6 +5015,7 @@ "timezone", "background_service", "custom_leagues", - "leagues" + "leagues", + "customization" ] } diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index 16820355..ac7ec070 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -278,6 +278,139 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -348,7 +481,10 @@ def render_game_card( # Draw scores (centered) score_x = (self.display_width - score_width) // 2 score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) # Draw period/status based on game type if game_type == "live": diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 93e128b9..a27e4627 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.6.2", + "version": "2.7.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,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.7.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.6.2", "released": "2026-08-05", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 87e9e179..02c013dc 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -477,6 +477,97 @@ 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}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + home_score = int(str(game.get("home_score", "")).strip()) + away_score = int(str(game.get("away_score", "")).strip()) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1076,6 +1167,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -2107,7 +2203,11 @@ def format_score(score): # date fits on the bottom line without colliding with the score. score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets From 43621ec25db4fad99c91c4d54960a3d51f675c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:50:44 +0000 Subject: [PATCH 2/3] fix(baseball): draw the recent scroll card's score white, not gold The scroll/Vegas recent card was the only place in any scoreboard that drew a final score gold. This plugin's own switch-mode scorebug draws it white, and so does every other sport, so the same game changed colour depending on which display mode you were in. Folded into the 1.23.0 entry rather than a separate bump: that version is not released yet, and this ships with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RRLLpvsvXgy1pskvaNVJWx --- plugins/baseball-scoreboard/README.md | 6 +++-- plugins/baseball-scoreboard/game_renderer.py | 6 +++-- plugins/baseball-scoreboard/manifest.json | 2 +- .../test_favorite_result_colors.py | 22 +++++++++---------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 22275ae2..0d10e719 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -433,8 +433,10 @@ a loss. } ``` -- Off by default. Until you enable it the score keeps exactly the color it has - today. +- Off by default. Until you enable it the score is drawn in the plain white the + scorebug uses everywhere else. (Before this release the scroll/Vegas recent + card drew the final score gold, out of step with the switch view and with + every other scoreboard; it is white now.) - Only finished games are colored. Live and upcoming cards are untouched. - A game needs exactly one favorite team. If neither side is a favorite, or both are, the score keeps its normal color. diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index cd025936..c8125f4a 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -561,14 +561,16 @@ def _render_recent_game(self, game: Dict) -> Image.Image: status_width = draw.textlength(status_text, font=self.fonts['time']) self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), self.fonts['time']) - # Score (centered) + # Score (centered). White, matching the switch-mode recent scorebug + # and every other scoreboard; this card used to be alone in drawing + # the final score gold. score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" score_width = draw.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 score_y = self.display_height - 14 self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'], - fill=self._recent_score_color(game, (255, 200, 0))) + fill=self._recent_score_color(game, (255, 255, 255))) # Records at bottom corners self._draw_records(draw, game) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c8fe482b..b7957532 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -33,7 +33,7 @@ { "version": "1.23.0", "released": "2026-08-05", - "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color. Also changes the recent card's untinted score from gold to white, matching this plugin's own switch-mode scorebug and every other scoreboard; it was the only card drawing a final score gold.", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/baseball-scoreboard/test_favorite_result_colors.py b/plugins/baseball-scoreboard/test_favorite_result_colors.py index 812ca155..ce381a2f 100644 --- a/plugins/baseball-scoreboard/test_favorite_result_colors.py +++ b/plugins/baseball-scoreboard/test_favorite_result_colors.py @@ -26,7 +26,7 @@ from sports import SportsCore # noqa: E402 WHITE = (255, 255, 255) -GOLD = (255, 200, 0) +AMBER = (255, 200, 0) # the built-in tie color GREEN = (0, 255, 0) RED = (255, 0, 0) @@ -94,7 +94,7 @@ def test_switch_mode_colors(): _check("favorite lost away", core._recent_score_color(_game("NYM", "ATL", "7", "1"), WHITE), RED) _check("tie", - core._recent_score_color(_game("ATL", "NYM", "3", "3"), WHITE), (255, 200, 0)) + core._recent_score_color(_game("ATL", "NYM", "3", "3"), WHITE), AMBER) _check("neither team is a favorite", core._recent_score_color(_game("NYM", "PHI", "4", "1"), WHITE), WHITE) @@ -160,18 +160,18 @@ def test_scroll_card_colors(): renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, "mlb": {"favorite_teams": ["ATL"]}}) - # The gold default the recent card already used is what an untinted card - # must keep, so an existing install sees no change until it opts in. + # With the feature off the caller's own default is handed straight back, + # whatever it is, so an existing install sees no change until it opts in. off = _renderer({"mlb": {"favorite_teams": ["ATL"]}}) _check("feature off keeps the card's own default", - off._recent_score_color(_game("ATL", "NYM", "5", "2"), GOLD), GOLD) + off._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) _check("favorite won", renderer._recent_score_color( - _game("ATL", "NYM", "5", "2"), GOLD), GREEN) + _game("ATL", "NYM", "5", "2"), WHITE), GREEN) _check("favorite lost", renderer._recent_score_color( - _game("ATL", "NYM", "2", "5"), GOLD), RED) + _game("ATL", "NYM", "2", "5"), WHITE), RED) _check("other matchup untouched", renderer._recent_score_color( - _game("NYM", "PHI", "4", "1"), GOLD), GOLD) + _game("NYM", "PHI", "4", "1"), WHITE), WHITE) # Only finished games are tinted; a live card keeps its normal color even # when the favorite happens to be ahead. @@ -190,20 +190,20 @@ def test_scroll_card_favorite_sources(): # renderer cannot find by name -- the game's stamped list carries them. stamped = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}}) _check("favorites stamped on the game", stamped._recent_score_color( - _game("ATL", "NYM", "5", "2", favorites=["ATL"]), GOLD), GREEN) + _game("ATL", "NYM", "5", "2", favorites=["ATL"]), WHITE), GREEN) # A league section the renderer can find, with nothing stamped (hand-built # game dicts, and config edits that beat the next data refresh). from_config = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, "mlb": {"favorite_teams": ["ATL"]}}) _check("favorites read from config", from_config._recent_score_color( - _game("ATL", "NYM", "5", "2"), GOLD), GREEN) + _game("ATL", "NYM", "5", "2"), WHITE), GREEN) # NRL keys favorites by ESPN id because its abbreviations collide. by_id = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, "nrl": {"favorite_teams": ["id-ATL"]}}) _check("favorites matched by id", by_id._recent_score_color( - _game("ATL", "NYM", "5", "2", league="nrl"), GOLD), GREEN) + _game("ATL", "NYM", "5", "2", league="nrl"), WHITE), GREEN) def test_scroll_card_nested_payload(): From 4ad34acc93edad7921ea6b01704a343d8010b504 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:02:12 +0000 Subject: [PATCH 3/3] fix(scoreboards): guard _coerce_rgb and align score parsing across both paths Addresses the CodeRabbit review on #259. Each helper is duplicated per plugin, so every one of the 16 copies is fixed, not just the sites the review named. _coerce_rgb accepted any 3-element iterable, so a configured colour of "123" unpacked into (1, 2, 3) -- a near-black -- instead of falling back. Only a 3-character string hit this (a short list or a longer one already failed the unpack), but it silently produced a colour where the intent was a fallback. Now requires a list or tuple of exactly three items. SportsCore._favorite_result parsed scores with int(str(...)) while GameRenderer._side_score used int(float(...)), so "5.0" coloured in scroll mode but not in switch mode. ESPN returns integer strings today so nothing was visibly broken, but the two paths must agree on what a usable score is; both now use int(float(...)). A new test asserts they agree rather than checking each in isolation. FAVORITE_RESULT_COLOR_DEFAULTS is annotated ClassVar (RUF012), matching the existing TOURNAMENT_ROUND_ORDER annotation in basketball's sports.py. Tests: the malformed-colour case is now a table including "123", and the scroll/Vegas path covers its own tie branch, default and configured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RRLLpvsvXgy1pskvaNVJWx --- plugins/afl-scoreboard/game_renderer.py | 8 +++- plugins/afl-scoreboard/sports.py | 14 ++++-- plugins/baseball-scoreboard/game_renderer.py | 8 +++- plugins/baseball-scoreboard/sports.py | 14 ++++-- .../test_favorite_result_colors.py | 47 ++++++++++++++++--- .../basketball-scoreboard/game_renderer.py | 8 +++- plugins/basketball-scoreboard/sports.py | 14 ++++-- plugins/football-scoreboard/game_renderer.py | 8 +++- plugins/football-scoreboard/sports.py | 14 ++++-- plugins/hockey-scoreboard/game_renderer.py | 8 +++- plugins/hockey-scoreboard/sports.py | 14 ++++-- plugins/lacrosse-scoreboard/game_renderer.py | 8 +++- plugins/lacrosse-scoreboard/sports.py | 14 ++++-- plugins/nrl-scoreboard/game_renderer.py | 8 +++- plugins/nrl-scoreboard/sports.py | 14 ++++-- plugins/soccer-scoreboard/game_renderer.py | 8 +++- plugins/soccer-scoreboard/sports.py | 14 ++++-- 17 files changed, 169 insertions(+), 54 deletions(-) diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index 3ad3b255..a7c2ca6a 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -15,7 +15,7 @@ import os import sys from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont # Add project root to path to import the shared logo downloader (same @@ -313,7 +313,7 @@ def _draw_text_with_outline( # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -322,6 +322,10 @@ def _draw_text_with_outline( @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 6ad3c058..08182b13 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -487,7 +487,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -496,6 +496,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -534,8 +538,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index c8125f4a..69120717 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -9,7 +9,7 @@ from datetime import datetime from pathlib import Path import os -from typing import Any, Dict, Optional +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont @@ -236,7 +236,7 @@ def set_rankings_cache(self, rankings: Dict[str, int]) -> None: # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -245,6 +245,10 @@ def set_rankings_cache(self, rankings: Dict[str, int]) -> None: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index f6c1b0e8..e902b981 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -422,7 +422,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -431,6 +431,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -469,8 +473,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/baseball-scoreboard/test_favorite_result_colors.py b/plugins/baseball-scoreboard/test_favorite_result_colors.py index ce381a2f..09f60585 100644 --- a/plugins/baseball-scoreboard/test_favorite_result_colors.py +++ b/plugins/baseball-scoreboard/test_favorite_result_colors.py @@ -128,6 +128,25 @@ def test_unusable_scores(): core._recent_score_color(_game("ATL", "NYM", "-", "-"), WHITE), WHITE) +def test_score_parsing_matches_across_paths(): + """The two render paths must agree on what counts as a usable score. + + They resolve the colour independently, so a value one accepts and the + other rejects would colour a game in the ticker but not the switch view. + """ + print("\n[score parsing parity]") + core = _core(["ATL"]) + renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "mlb": {"favorite_teams": ["ATL"]}}) + for home, away, expected in [("5", "2", GREEN), ("5.0", "2.0", GREEN), + ("2", "5", RED), ("", "", WHITE), ("-", "-", WHITE)]: + game = _game("ATL", "NYM", home, away) + switch = core._recent_score_color(game, WHITE) + scroll = renderer._recent_score_color(game, WHITE) + _check(f"switch and scroll agree on {home!r}-{away!r}", (switch, scroll), + (expected, expected)) + + def test_custom_colors(): print("\n[custom colors]") core = _core(["ATL"], config={"customization": {"favorite_result_colors": { @@ -147,12 +166,16 @@ def test_custom_colors(): _check("out-of-range channels are clamped", core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), (255, 0, 12)) - core = _core(["ATL"], config={"customization": {"favorite_result_colors": { - "enabled": True, - "win_color": "not a color", - }}}) - _check("garbage color falls back to the built-in win color", - core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), GREEN) + for junk in ("not a color", "123", [1, 2], [1, 2, 3, 4], 42, None, + {"r": 1, "g": 2, "b": 3}): + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": junk, + }}}) + # "123" is the interesting one: it is iterable and three items long, so + # an unguarded unpack turns it into the near-black (1, 2, 3). + _check(f"malformed color {junk!r} falls back to the built-in win color", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), GREEN) def test_scroll_card_colors(): @@ -173,6 +196,17 @@ def test_scroll_card_colors(): _check("other matchup untouched", renderer._recent_score_color( _game("NYM", "PHI", "4", "1"), WHITE), WHITE) + # The renderer has its own tie branch, independent of SportsCore's. + _check("tie uses the built-in tie color", renderer._recent_score_color( + _game("ATL", "NYM", "3", "3"), WHITE), AMBER) + tie_configured = _renderer({ + "customization": {"favorite_result_colors": { + "enabled": True, "tie_color": [80, 80, 255]}}, + "mlb": {"favorite_teams": ["ATL"]}, + }) + _check("tie uses the configured tie color", tie_configured._recent_score_color( + _game("ATL", "NYM", "3", "3"), WHITE), (80, 80, 255)) + # Only finished games are tinted; a live card keeps its normal color even # when the favorite happens to be ahead. _check("live game not tinted", renderer._score_color_for( @@ -228,6 +262,7 @@ def main(): test_disabled_by_default, test_no_single_team_to_root_for, test_unusable_scores, + test_score_parsing_matches_across_paths, test_custom_colors, test_scroll_card_colors, test_scroll_card_favorite_sources, diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index e402d8ba..520aaa7c 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -291,7 +291,7 @@ def _draw_text_with_outline( # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -300,6 +300,10 @@ def _draw_text_with_outline( @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index ab0c0796..cc1ba50a 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, ClassVar, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -324,7 +324,7 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -333,6 +333,10 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -371,8 +375,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index b712c7bf..1cf52a09 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple, Union +from typing import Any, ClassVar, Dict, Optional, Tuple, Union from PIL import Image, ImageDraw, ImageFont try: import freetype # noqa: F401 @@ -382,7 +382,7 @@ def _draw_text_with_outline( # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -391,6 +391,10 @@ def _draw_text_with_outline( @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 17e3cd36..fe9890aa 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -397,7 +397,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -406,6 +406,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -444,8 +448,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index 4ebfacd4..a93d85b7 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -326,7 +326,7 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -335,6 +335,10 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 2b76d57f..8820996c 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -348,7 +348,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -357,6 +357,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -395,8 +399,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 94a49b25..9b66b7ad 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -335,7 +335,7 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -344,6 +344,10 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 6b25480c..ea991e14 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -349,7 +349,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -358,6 +358,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -396,8 +400,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index fa42942f..0bb85d9a 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -288,7 +288,7 @@ def _draw_text_with_outline( # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -297,6 +297,10 @@ def _draw_text_with_outline( @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 68144f1c..d93f08bc 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -506,7 +506,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -515,6 +515,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -553,8 +557,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index ac7ec070..eebf4857 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -288,7 +288,7 @@ def _draw_text_with_outline( # keeps the color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -297,6 +297,10 @@ def _draw_text_with_outline( @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 02c013dc..579406cf 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -488,7 +488,7 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: # score color it has today until the user opts in. # ------------------------------------------------------------------ - FAVORITE_RESULT_COLOR_DEFAULTS = { + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), @@ -497,6 +497,10 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: @staticmethod def _coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): @@ -535,8 +539,10 @@ def _favorite_result(self, game: Dict) -> Optional[str]: return None try: - home_score = int(str(game.get("home_score", "")).strip()) - away_score = int(str(game.get("away_score", "")).strip()) + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) except (TypeError, ValueError): return None