From 3a641dbff8889a47d860503ca808ab662cc9f8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:45:36 -0400 Subject: [PATCH 1/6] fix(sports): align the odds row, and honour the offsets that control it The same element sat in three different places. Baseball drew the odds a whole text row below the top -- status_bbox[3] + 2, which measures 8px with the 4x6 font and 10px with PressStart2P -- while basketball drew them at the top edge with an offset applied, and football, soccer, afl, nrl and ufc drew them at a fixed 0,0. On a 32px card that put baseball's odds a third of the card lower than everyone else's. Baseball now uses the top edge too. Its lower row was not arbitrary: it is the only scoreboard with text centred on that row, and the odds are drawn hard left and hard right, so on a narrow panel they meet. That is now decided by measuring the actual strings rather than by assuming -- on a 64px panel "O/U: 8.5" clears the inning by 2px while "O/U: 12.5" overlaps it, and double-digit over/unders are ordinary in baseball. The odds start at the top edge and step down one row only when these particular strings would collide, so every panel 128px and wider is unconditionally top-aligned and a 64px panel keeps the top edge whenever the text fits. Football and ufc declare customization.layout.odds in their config schemas, so the web UI offered x_offset and y_offset, and both renderers ignored them entirely. A control that visibly does nothing is worse than no control: it tells the user the position cannot be fixed. Both now apply them. Soccer, afl and nrl had no odds control at all. They get the same schema block and the same wiring, so the element behaves the same way everywhere. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 12 +- plugins/afl-scoreboard/config_schema.json | 22 ++- plugins/afl-scoreboard/game_renderer.py | 21 ++- plugins/afl-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/game_renderer.py | 91 ++++++++++-- plugins/baseball-scoreboard/manifest.json | 8 +- .../test_odds_placement.py | 140 ++++++++++++++++++ plugins/football-scoreboard/game_renderer.py | 27 ++-- plugins/football-scoreboard/manifest.json | 10 +- plugins/nrl-scoreboard/config_schema.json | 22 ++- plugins/nrl-scoreboard/game_renderer.py | 21 ++- plugins/nrl-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/config_schema.json | 22 ++- plugins/soccer-scoreboard/game_renderer.py | 21 ++- plugins/soccer-scoreboard/manifest.json | 8 +- plugins/ufc-scoreboard/manifest.json | 8 +- plugins/ufc-scoreboard/sports.py | 28 ++-- 17 files changed, 404 insertions(+), 73 deletions(-) create mode 100644 plugins/baseball-scoreboard/test_odds_placement.py diff --git a/plugins.json b/plugins.json index bac54eae..9c9bd991 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.24.0" + "latest_version": "1.24.1" }, { "id": "basketball-scoreboard", @@ -240,7 +240,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.14.0" + "latest_version": "2.14.1" }, { "id": "geochron", @@ -760,7 +760,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.8.0" + "latest_version": "2.9.0" }, { "id": "static-image", @@ -905,7 +905,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.3", + "latest_version": "1.3.4", "icon": "fas fa-fist-raised" }, { @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.5.0", + "latest_version": "1.6.0", "last_updated": "2026-08-05" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.5.0" + "latest_version": "1.6.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index b9338238..95399611 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -1131,6 +1131,25 @@ } }, "additionalProperties": false + }, + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false } }, "x-propertyOrder": [ @@ -1140,7 +1159,8 @@ "status_text", "date", "time", - "records" + "records", + "odds" ], "additionalProperties": false }, diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index 12a231a4..6e4576e4 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -908,13 +908,18 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_text = str(favored_spread) font = self.fonts["detail"] + # Odds had no layout control at all here, while baseball, + # basketball, football and ufc all expose one. Same element, + # same card, so it gets the same knob. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) - spread_x = self.display_width - spread_width - spread_y = 0 + spread_x = self.display_width - spread_width + odds_x_offset else: - spread_x = 0 - spread_y = 0 + spread_x = 0 + odds_x_offset + spread_y = 0 + odds_y_offset self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) @@ -926,12 +931,12 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": - ou_x = 0 + ou_x = 0 + odds_x_offset elif favored_side == "away": - ou_x = self.display_width - ou_width + ou_x = self.display_width - ou_width + odds_x_offset else: - ou_x = (self.display_width - ou_width) // 2 - ou_y = 0 + ou_x = (self.display_width - ou_width) // 2 + odds_x_offset + ou_y = 0 + odds_y_offset self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 27102d00..073b5be4 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.5.0", + "version": "1.6.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.6.0", + "released": "2026-08-11", + "notes": "Add layout offsets for the betting odds. The spread and over/under were pinned to the top corners with no way to nudge them, while baseball, basketball, football and ufc all expose customization.layout.odds. Same element and same card, so it now takes the same knob.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.5.0", "released": "2026-08-06", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 99071a82..9a5b72bc 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -531,7 +531,7 @@ def _render_live_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds']) + self._draw_dynamic_odds(draw, game['odds'], game) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -587,7 +587,7 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds']) + self._draw_dynamic_odds(draw, game['odds'], game) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -920,7 +920,7 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds']) + self._draw_dynamic_odds(draw, game['odds'], game) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -994,8 +994,51 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: except (TypeError, ValueError): return default - def _draw_dynamic_odds(self, draw, odds: Dict) -> None: - """Draw odds with dynamic positioning based on favored team.""" + def _inning_text(self, game: Optional[Dict]) -> str: + """The centred top-row text, built exactly as the scorebug draws it.""" + if not game: + return "" + inning_half = game.get('inning_half', 'top') + inning_num = game.get('inning', 1) + if game.get('is_final'): + return "FINAL" + if inning_half == 'end': + return f"E{inning_num}" + if inning_half == 'mid': + return f"M{inning_num}" + symbol = "\u25b2" if inning_half == 'top' else "\u25bc" + return f"{symbol}{inning_num}" + + def _odds_would_hit_inning(self, draw, game: Optional[Dict], placements) -> bool: + """Whether any odds text overlaps the centred inning text on the top row. + + Baseball is the only scoreboard with something centred on that row, so + it is the only one that can collide. Deciding by measurement rather + than by panel size keeps the rule honest: what matters is whether these + particular strings fit beside each other, and a two-digit over/under is + several pixels wider than a one-digit one. + """ + text = self._inning_text(game) + if not text: + return False + try: + inning_font = self.fonts['time'] + inning_width = draw.textbbox((0, 0), text, font=inning_font)[2] + except Exception: + return False + left = (self.display_width - inning_width) // 2 + right = left + inning_width + # One pixel of breathing room either side, so glyphs do not touch. + return any(x < right + 1 and x + width > left - 1 + for _text, x, width in placements) + + def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None) -> None: + """Draw odds with dynamic positioning based on favored team. + + `game` is optional and used only to measure the centred inning text, + so the odds can step down a row on a panel too narrow to fit beside + it. Omitting it simply skips that check. + """ try: if not odds: return @@ -1031,12 +1074,19 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: odds_x_offset = self._get_layout_offset('odds', 'x_offset') odds_y_offset = self._get_layout_offset('odds', 'y_offset') - # Odds row below the status/inning text row - status_bbox = draw.textbbox((0, 0), "A", font=self.fonts['detail']) - odds_y = status_bbox[3] + 2 + odds_y_offset - - # Show the negative spread on the appropriate side + # Top edge, matching every other scoreboard. This used to sit a + # whole text row lower (status_bbox[3] + 2, which measured 8-10px + # depending on the detail font) to clear the centred inning text, + # but that made baseball the odd one out: the same element landed a + # third of a 32px card lower here than on football, basketball, + # soccer and the rest. Odds are drawn hard left and hard right + # while the inning sits centred, so they only meet on a narrow + # panel -- and odds_y_offset is there to nudge it when they do. font = self.fonts['detail'] + + # Work out both texts and their spans before drawing either, so the + # row can be chosen once with full knowledge of what has to fit. + placements = [] if favored_spread is not None: spread_text = str(favored_spread) spread_width = draw.textlength(spread_text, font=font) @@ -1044,9 +1094,8 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: spread_x = self.display_width - spread_width + odds_x_offset else: spread_x = 0 + odds_x_offset - self._draw_text_with_outline(draw, spread_text, (spread_x, odds_y), font, fill=(0, 255, 0)) + placements.append((spread_text, spread_x, spread_width)) - # Show over/under on opposite side over_under = odds.get('over_under') if over_under is not None and isinstance(over_under, (int, float)): ou_text = f"O/U: {over_under}" @@ -1057,7 +1106,23 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: ou_x = self.display_width - ou_width + odds_x_offset else: ou_x = (self.display_width - ou_width) // 2 + odds_x_offset - self._draw_text_with_outline(draw, ou_text, (ou_x, odds_y), font, fill=(0, 255, 0)) + placements.append((ou_text, ou_x, ou_width)) + + if not placements: + return + + odds_y = 0 + odds_y_offset + if self._odds_would_hit_inning(draw, game, placements): + # Step down one text row, which is where these used to live + # unconditionally. Measured rather than keyed to a panel size: + # it is the text widths that decide, and a two-digit over/under + # ("O/U: 12.5") overlaps on a 64px panel where "O/U: 8.5" clears + # it by 2px. Wider panels never reach the centre and never move. + row = draw.textbbox((0, 0), "A", font=font)[3] + 2 + odds_y += row + + for text, x, _width in placements: + self._draw_text_with_outline(draw, text, (x, odds_y), font, fill=(0, 255, 0)) except Exception: self.logger.exception("Error drawing odds") diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index f60e13e2..73fafe65 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.24.0", + "version": "1.24.1", "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.24.1", + "released": "2026-08-11", + "notes": "Draw betting odds at the top edge, matching every other scoreboard. They sat a whole text row lower (8px with the 4x6 font, 10px with PressStart2P), so the same element landed a third of a 32px card lower here than on football, basketball, soccer, afl, nrl and ufc. The lower row existed to clear the centred inning text, which baseball alone draws on that row, so the odds now step down only when they would genuinely collide -- measured from the actual strings rather than keyed to a panel size, because it is the text widths that decide: on a 64px panel \"O/U: 8.5\" clears the inning by 2px while \"O/U: 12.5\" overlaps it. Every panel 128px and wider uses the top edge unconditionally.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.24.0", "released": "2026-08-06", diff --git a/plugins/baseball-scoreboard/test_odds_placement.py b/plugins/baseball-scoreboard/test_odds_placement.py new file mode 100644 index 00000000..32401b4d --- /dev/null +++ b/plugins/baseball-scoreboard/test_odds_placement.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Tests where the odds text sits on a baseball scroll card. + +Two things are under test. + +Odds used to be drawn a whole text row below the top -- `status_bbox[3] + 2`, +which measures 8px with the 4x6 font and 10px with PressStart2P -- while every +other scoreboard drew them at the top edge. On a 32px card that put the same +element a third of the card lower on baseball than on football, basketball, +soccer, afl, nrl and ufc, which is what "too low on some sports" looked like. + +The row existed for a reason, though: baseball is the only scoreboard with +text centred on that row (the inning), and the odds are drawn hard left and +hard right. Measured on a 64px panel, "O/U: 8.5" clears the inning by 2px but +"O/U: 12.5" overlaps it by 1-3px, and double-digit over/unders are ordinary in +baseball. So the odds now start at the top edge and step down only when these +particular strings would actually collide -- a measurement, not a panel-size +rule, since it is the text widths that decide. + +Run: /bin/python plugins/baseball-scoreboard/test_odds_placement.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import game_renderer as gr # noqa: E402 + + +def _font(): + """The core's 4x6, or PIL's default if the core tree is not to hand.""" + import os + core = os.environ.get("LEDMATRIX_CORE", "") + for base in (core, "."): + p = Path(base) / "assets" / "fonts" / "4x6-font.ttf" + if p.exists(): + return ImageFont.truetype(str(p), 6) + return ImageFont.load_default() + + +FONT = _font() +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def odds_y(width, height, over_under, inning_half="top", inning=3, + y_offset=0, x_offset=0): + """Render the odds and report the y they landed on.""" + r = gr.GameRenderer.__new__(gr.GameRenderer) + r.display_width, r.display_height = width, height + r.config = {} + r.logger = type("L", (), {m: (lambda *a, **k: None) + for m in ("exception", "error", "warning", "debug")})() + r.fonts = {"detail": FONT, "time": FONT} + r._get_layout_offset = lambda e, a, default=0: ( + y_offset if a == "y_offset" else x_offset) + drawn = [] + r._draw_text_with_outline = ( + lambda draw, t, xy, font, fill=None: drawn.append((t, xy[0], xy[1]))) + draw = ImageDraw.Draw(Image.new("RGB", (width, height))) + game = {"inning_half": inning_half, "inning": inning} + r._draw_dynamic_odds(draw, { + "spread": -1.5, "over_under": over_under, + "home_team_odds": {"spread_odds": -1.5}, + "away_team_odds": {"spread_odds": 1.5}, + }, game) + if not drawn: + return None, drawn + return max(y for _t, _x, y in drawn), drawn + + +def main(): + print("odds sit at the top edge, like every other scoreboard") + for w, h in ((128, 32), (128, 64), (256, 64), (512, 64)): + y, _ = odds_y(w, h, 8.5) + check("%dx%d top edge" % (w, h), y == 0, "y=%s" % y) + y, _ = odds_y(w, h, 12.5) + check("%dx%d top edge with a two-digit O/U" % (w, h), y == 0, "y=%s" % y) + + print("\nexcept where they would actually hit the centred inning text") + y_short, _ = odds_y(64, 32, 8.5) + y_long, _ = odds_y(64, 32, 12.5) + check("64x32 stays at the top when the O/U is short", y_short == 0, + "y=%s" % y_short) + check("64x32 steps down when the O/U is wide", y_long > 0, "y=%s" % y_long) + check("and steps down by about one text row", 4 <= y_long <= 12, + "y=%s" % y_long) + + print("\nthe step is decided by the text, not the panel size") + # A long inning label ("FINAL") is wider than a short one, so it can push + # the odds down at a width where a short label does not. + y_final, _ = odds_y(64, 32, 8.5, inning_half="top") + check("a narrow panel with short strings still uses the top edge", + y_final == 0, "y=%s" % y_final) + + print("\nthe manual offsets still apply") + y0, _ = odds_y(256, 64, 8.5) + y5, _ = odds_y(256, 64, 8.5, y_offset=5) + check("y_offset moves it", y5 == y0 + 5, "%s vs %s" % (y5, y0)) + _, drawn0 = odds_y(256, 64, 8.5) + _, drawn7 = odds_y(256, 64, 8.5, x_offset=7) + check("x_offset moves it", + all(b[1] - a[1] == 7 for a, b in zip(drawn0, drawn7)), + "%r vs %r" % ([d[1] for d in drawn0], [d[1] for d in drawn7])) + + print("\nnothing is drawn when there are no odds to draw") + r = gr.GameRenderer.__new__(gr.GameRenderer) + r.display_width = r.display_height = 64 + r.config = {} + r.logger = type("L", (), {m: (lambda *a, **k: None) + for m in ("exception", "error", "warning", "debug")})() + r.fonts = {"detail": FONT, "time": FONT} + r._get_layout_offset = lambda e, a, default=0: 0 + got = [] + r._draw_text_with_outline = lambda *a, **k: got.append(a) + r._draw_dynamic_odds(ImageDraw.Draw(Image.new("RGB", (64, 64))), {}, {}) + check("empty odds draw nothing", not got, "%d draws" % len(got)) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index d4a9acd3..18219158 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -1437,18 +1437,25 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None favored_side = "away" # Show the negative spread + # customization.layout.odds is declared in this plugin's config + # schema, so the web UI offers x_offset and y_offset -- but nothing + # here ever read them, and the odds drew at a fixed 0,0. A control + # that visibly does nothing is worse than no control, since the + # user concludes the position is unfixable. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + if favored_spread is not None: spread_text = str(favored_spread) font = self.fonts["detail"] - + if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) - spread_x = self.display_width - spread_width - spread_y = 0 + spread_x = self.display_width - spread_width + odds_x_offset else: - spread_x = 0 - spread_y = 0 - + spread_x = 0 + odds_x_offset + spread_y = 0 + odds_y_offset + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) # Show over/under on opposite side @@ -1459,12 +1466,12 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": - ou_x = 0 + ou_x = 0 + odds_x_offset elif favored_side == "away": - ou_x = self.display_width - ou_width + ou_x = self.display_width - ou_width + odds_x_offset else: - ou_x = (self.display_width - ou_width) // 2 - ou_y = 0 + ou_x = (self.display_width - ou_width) // 2 + odds_x_offset + ou_y = 0 + odds_y_offset self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 22649e6a..b6732377 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.14.0", + "version": "2.14.1", "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.14.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Apply the betting-odds layout offsets the config schema already advertised. customization.layout.odds declares x_offset and y_offset, so the web UI offered both, but the renderer drew the spread and over/under at a fixed 0,0 and never read them -- a control that visibly did nothing, which reads as the position being unfixable." + }, { "version": "2.14.0", "released": "2026-08-11", @@ -108,7 +114,7 @@ "released": "2026-07-10", "version": "2.8.0", "ledmatrix_min_version": "2.0.0", - "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" \u2014 rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." + "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" — rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." }, { "released": "2026-07-08", diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index f68ad7d1..62690caa 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -1100,6 +1100,25 @@ } }, "additionalProperties": false + }, + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false } }, "x-propertyOrder": [ @@ -1109,7 +1128,8 @@ "status_text", "date", "time", - "records" + "records", + "odds" ], "additionalProperties": false }, diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index a99ff321..086ab003 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -883,13 +883,18 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_text = str(favored_spread) font = self.fonts["detail"] + # Odds had no layout control at all here, while baseball, + # basketball, football and ufc all expose one. Same element, + # same card, so it gets the same knob. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) - spread_x = self.display_width - spread_width - spread_y = 0 + spread_x = self.display_width - spread_width + odds_x_offset else: - spread_x = 0 - spread_y = 0 + spread_x = 0 + odds_x_offset + spread_y = 0 + odds_y_offset self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) @@ -901,12 +906,12 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": - ou_x = 0 + ou_x = 0 + odds_x_offset elif favored_side == "away": - ou_x = self.display_width - ou_width + ou_x = self.display_width - ou_width + odds_x_offset else: - ou_x = (self.display_width - ou_width) // 2 - ou_y = 0 + ou_x = (self.display_width - ou_width) // 2 + odds_x_offset + ou_y = 0 + odds_y_offset self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 5a30d84f..924561af 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.5.0", + "version": "1.6.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.6.0", + "released": "2026-08-11", + "notes": "Add layout offsets for the betting odds. The spread and over/under were pinned to the top corners with no way to nudge them, while baseball, basketball, football and ufc all expose customization.layout.odds. Same element and same card, so it now takes the same knob.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.5.0", "released": "2026-08-06", diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 9e8ba441..2bc1a90a 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -5203,6 +5203,25 @@ } }, "additionalProperties": false + }, + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false } }, "x-propertyOrder": [ @@ -5212,7 +5231,8 @@ "status_text", "date", "time", - "records" + "records", + "odds" ], "additionalProperties": false }, diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index 37b20b1a..ec7c902b 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -883,13 +883,18 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None spread_text = str(favored_spread) font = self.fonts["detail"] + # Odds had no layout control at all here, while baseball, + # basketball, football and ufc all expose one. Same element, + # same card, so it gets the same knob. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) - spread_x = self.display_width - spread_width - spread_y = 0 + spread_x = self.display_width - spread_width + odds_x_offset else: - spread_x = 0 - spread_y = 0 + spread_x = 0 + odds_x_offset + spread_y = 0 + odds_y_offset self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) @@ -901,12 +906,12 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None ou_width = draw.textlength(ou_text, font=font) if favored_side == "home": - ou_x = 0 + ou_x = 0 + odds_x_offset elif favored_side == "away": - ou_x = self.display_width - ou_width + ou_x = self.display_width - ou_width + odds_x_offset else: - ou_x = (self.display_width - ou_width) // 2 - ou_y = 0 + ou_x = (self.display_width - ou_width) // 2 + odds_x_offset + ou_y = 0 + odds_y_offset self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 44708617..f9c6be2c 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.8.0", + "version": "2.9.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.9.0", + "released": "2026-08-11", + "notes": "Add layout offsets for the betting odds. The spread and over/under were pinned to the top corners with no way to nudge them, while baseball, basketball, football and ufc all expose customization.layout.odds. Same element and same card, so it now takes the same knob.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.8.0", "released": "2026-08-06", diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 19a03669..96ac535b 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.3.3", + "version": "1.3.4", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.3.4", + "released": "2026-08-11", + "notes": "Apply the betting-odds layout offsets the config schema already advertised. customization.layout.odds declares x_offset and y_offset, so the web UI offered both, but the renderer drew the spread and over/under at a fixed 0,0 and never read them.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.3", "released": "2026-08-05", diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index 52204619..6009fa8b 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -434,11 +434,17 @@ def _draw_dynamic_odds( spread_text = str(favored_spread) font = self.fonts["detail"] # Use detail font for odds + # customization.layout.odds is declared in this plugin's config + # schema, so the web UI offers x_offset and y_offset -- but + # nothing here read them and the odds drew at a fixed 0,0. + odds_x_offset = self._get_layout_offset('odds', 'x_offset') + odds_y_offset = self._get_layout_offset('odds', 'y_offset') + if favored_side == "home": # Home team is favored, show spread on right side spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 + spread_x = width - spread_width + odds_x_offset # Top right + spread_y = 0 + odds_y_offset self._draw_text_with_outline( draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) ) @@ -447,8 +453,8 @@ def _draw_dynamic_odds( ) else: # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 + spread_x = 0 + odds_x_offset # Top left + spread_y = 0 + odds_y_offset self._draw_text_with_outline( draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) ) @@ -462,25 +468,27 @@ def _draw_dynamic_odds( ou_text = f"O/U: {over_under}" font = self.fonts["detail"] # Use detail font for odds ou_width = draw.textlength(ou_text, font=font) + odds_x_offset = self._get_layout_offset('odds', 'x_offset') + odds_y_offset = self._get_layout_offset('odds', 'y_offset') if favored_side == "home": # Home favored, show O/U on left side (opposite of spread) - ou_x = 0 # Top left - ou_y = 0 + ou_x = 0 + odds_x_offset # Top left + ou_y = 0 + odds_y_offset self.logger.debug( f"Showing O/U '{ou_text}' on left side (home favored)" ) elif favored_side == "away": # Away favored, show O/U on right side (opposite of spread) - ou_x = width - ou_width # Top right - ou_y = 0 + ou_x = width - ou_width + odds_x_offset # Top right + ou_y = 0 + odds_y_offset self.logger.debug( f"Showing O/U '{ou_text}' on right side (away favored)" ) else: # No clear favorite, show O/U in center - ou_x = (width - ou_width) // 2 - ou_y = 0 + ou_x = (width - ou_width) // 2 + odds_x_offset + ou_y = 0 + odds_y_offset self.logger.debug( f"Showing O/U '{ou_text}' in center (no clear favorite)" ) From 27e7bf4387e606a7641c33ab76c98ea8a96be591 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:54:28 -0400 Subject: [PATCH 2/6] test(baseball): pass the optional game by keyword The plugin modules load under bare names, so a static analyser resolving `game_renderer` across the repo can bind another plugin's copy, whose _draw_dynamic_odds takes one argument fewer. Naming the parameter makes the call unambiguous to both a reader and a checker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/baseball-scoreboard/test_odds_placement.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/baseball-scoreboard/test_odds_placement.py b/plugins/baseball-scoreboard/test_odds_placement.py index 32401b4d..13b3740d 100644 --- a/plugins/baseball-scoreboard/test_odds_placement.py +++ b/plugins/baseball-scoreboard/test_odds_placement.py @@ -78,7 +78,7 @@ def odds_y(width, height, over_under, inning_half="top", inning=3, "spread": -1.5, "over_under": over_under, "home_team_odds": {"spread_odds": -1.5}, "away_team_odds": {"spread_odds": 1.5}, - }, game) + }, game=game) if not drawn: return None, drawn return max(y for _t, _x, y in drawn), drawn @@ -128,7 +128,7 @@ def main(): r._get_layout_offset = lambda e, a, default=0: 0 got = [] r._draw_text_with_outline = lambda *a, **k: got.append(a) - r._draw_dynamic_odds(ImageDraw.Draw(Image.new("RGB", (64, 64))), {}, {}) + r._draw_dynamic_odds(ImageDraw.Draw(Image.new("RGB", (64, 64))), {}, game={}) check("empty odds draw nothing", not got, "%d draws" % len(got)) print("\n%s" % ("FAILED: %d" % len(failures) if failures From dcc0e71f0e1b90a45567155d8b84de190115157b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:17:54 -0400 Subject: [PATCH 3/6] fix(sports): draw odds without a spread, and measure the right top row Two defects this PR introduced, both found by CodeRabbit. The layout offsets were read inside `if favored_spread is not None`, while the over/under block below uses them unconditionally. A game priced with a total and no spread -- ordinary enough -- raised UnboundLocalError, and the bare except around the method swallowed it, so the card drew no odds at all rather than drawing the total. Affects afl, nrl and soccer; the offsets are now read before either branch. Baseball's collision check always measured the live card's inning indicator, but _draw_dynamic_odds is called from three renderers and they do not centre the same string: a recent card draws "Final" and an upcoming card a time or a date. On a narrow panel the odds were being placed against a string that was not on the screen. Each caller now passes what it actually draws, and the upcoming card's choice is derived in one place so it cannot drift from the code that draws it. The three new tests fail exactly the total-with-no-spread case against the previous commit; the baseball placement test gains recent and upcoming cases across panel sizes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 8 +- plugins/afl-scoreboard/game_renderer.py | 13 ++- plugins/afl-scoreboard/manifest.json | 8 +- .../test_odds_without_spread.py | 103 ++++++++++++++++++ plugins/baseball-scoreboard/game_renderer.py | 62 ++++++++--- plugins/baseball-scoreboard/manifest.json | 14 ++- .../test_odds_placement.py | 28 ++++- plugins/nrl-scoreboard/game_renderer.py | 13 ++- plugins/nrl-scoreboard/manifest.json | 8 +- .../test_odds_without_spread.py | 91 ++++++++++++++++ plugins/soccer-scoreboard/game_renderer.py | 13 ++- plugins/soccer-scoreboard/manifest.json | 12 +- .../test_odds_without_spread.py | 91 ++++++++++++++++ 13 files changed, 414 insertions(+), 50 deletions(-) create mode 100644 plugins/afl-scoreboard/test_odds_without_spread.py create mode 100644 plugins/nrl-scoreboard/test_odds_without_spread.py create mode 100644 plugins/soccer-scoreboard/test_odds_without_spread.py diff --git a/plugins.json b/plugins.json index 9c9bd991..5a9f1c05 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.24.1" + "latest_version": "1.24.2" }, { "id": "basketball-scoreboard", @@ -760,7 +760,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.9.0" + "latest_version": "2.9.1" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.6.0", + "latest_version": "1.6.1", "last_updated": "2026-08-05" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.6.0" + "latest_version": "1.6.1" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index 6e4576e4..1c208bbe 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -903,16 +903,17 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None favored_spread = away_spread favored_side = "away" + # Read once, before either branch. These used to be read inside + # the spread branch, but the over/under below uses them too -- so + # a game with a total and no spread raised UnboundLocalError, and + # the except swallowed it, drawing no odds at all. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + # Show the negative spread if favored_spread is not None: spread_text = str(favored_spread) font = self.fonts["detail"] - - # Odds had no layout control at all here, while baseball, - # basketball, football and ufc all expose one. Same element, - # same card, so it gets the same knob. - odds_x_offset = self._layout_offset('odds', 'x_offset') - odds_y_offset = self._layout_offset('odds', 'y_offset') if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 073b5be4..ef8c7b32 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.6.0", + "version": "1.6.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "released": "2026-08-12", + "version": "1.6.1", + "ledmatrix_min_version": "2.0.0", + "notes": "Draw the odds when a game is priced with a total and no spread. The layout offsets were read inside the spread branch while the over/under below uses them too, so such a game raised UnboundLocalError and the surrounding except swallowed it -- the card drew no odds at all." + }, { "version": "1.6.0", "released": "2026-08-11", diff --git a/plugins/afl-scoreboard/test_odds_without_spread.py b/plugins/afl-scoreboard/test_odds_without_spread.py new file mode 100644 index 00000000..98e04418 --- /dev/null +++ b/plugins/afl-scoreboard/test_odds_without_spread.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Tests that odds still draw when a game has a total but no spread. + +The layout offsets used to be read inside `if favored_spread is not None`, +while the over/under block below uses them unconditionally. A game priced +with a total and no spread -- ordinary enough -- therefore raised +UnboundLocalError, and the bare `except` around the whole method swallowed +it, so the card drew no odds at all rather than drawing the total. + +Run: /bin/python plugins/afl-scoreboard/test_odds_without_spread.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +# This renderer imports src.logo_downloader at module scope, so the core tree +# has to be importable. LEDMATRIX_CORE points at a checkout. +import os +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import game_renderer as gr # noqa: E402 + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def _renderer(drawn): + r = gr.GameRenderer.__new__(gr.GameRenderer) + r.display_width, r.display_height = 128, 64 + r.config = {} + r.logger = type("L", (), {m: (lambda *a, **k: None) + for m in ("exception", "error", "warning", "debug", "info")})() + font = ImageFont.load_default() + r.fonts = {"detail": font, "time": font, "score": font, "team": font} + r._layout_offset = lambda e, a, default=0: 0 + r._draw_text_with_outline = ( + lambda draw, t, xy, font, fill=None: drawn.append(t)) + return r + + +def main(): + draw = ImageDraw.Draw(Image.new("RGB", (128, 64))) + + print("a total with no spread still draws") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, {"over_under": 47.5}) + check("the over/under reached the panel", any("47.5" in t for t in drawn), + "drew %r" % drawn) + + print("\nand the ordinary case is unchanged") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, { + "spread": -3.5, "over_under": 47.5, + "home_team_odds": {"spread_odds": -3.5}, + "away_team_odds": {"spread_odds": 3.5}, + }) + check("both spread and total drew", len(drawn) >= 2, "drew %r" % drawn) + + print("\na spread with no total still draws") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, { + "spread": -3.5, + "home_team_odds": {"spread_odds": -3.5}, + "away_team_odds": {"spread_odds": 3.5}, + }) + check("the spread reached the panel", any("3.5" in t for t in drawn), + "drew %r" % drawn) + + print("\nempty odds draw nothing") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, {}) + check("nothing drawn", not drawn, "drew %r" % drawn) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 9a5b72bc..05efc944 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -587,7 +587,7 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game) + self._draw_dynamic_odds(draw, game['odds'], game, top_text="Final") main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -815,6 +815,28 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: str(game.get("game_time", "") or ""), ) + def _upcoming_date_time_texts(self, game: Dict): + """The date and time strings an upcoming card would draw.""" + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + return date_text, time_text + + def _upcoming_top_row_text(self, game: Dict) -> str: + """Whatever an upcoming card centres on its top row, or "". + + Read by the odds collision check, which has to measure the string that + is actually on the panel. Derived here rather than in the caller so it + cannot drift from _draw_upcoming_game_status, which makes the same + choice a few lines below. + """ + if self._upcoming_center_mode() == "date_time": + return "" # both are stacked in the middle; the top row is free + date_text, time_text = self._upcoming_date_time_texts(game) + return date_text if self._scroll_card_option("swap_date_time", False) else time_text + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: """Draw the date and time around an upcoming card. @@ -825,11 +847,7 @@ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: if self._upcoming_center_mode() == "date_time": return - date_raw, time_raw = self._upcoming_date_and_time(game) - date_text = (self._format_game_date(date_raw, game) - if self._scroll_card_option("show_date", True) else "") - time_text = (self._format_game_time(time_raw) - if self._scroll_card_option("show_time", True) else "") + date_text, time_text = self._upcoming_date_time_texts(game) if self._scroll_card_option("swap_date_time", False): top_text, top_el, bottom_text, bottom_el = ( @@ -920,7 +938,8 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game) + self._draw_dynamic_odds(draw, game['odds'], game, + top_text=self._upcoming_top_row_text(upcoming)) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -1009,21 +1028,25 @@ def _inning_text(self, game: Optional[Dict]) -> str: symbol = "\u25b2" if inning_half == 'top' else "\u25bc" return f"{symbol}{inning_num}" - def _odds_would_hit_inning(self, draw, game: Optional[Dict], placements) -> bool: - """Whether any odds text overlaps the centred inning text on the top row. + def _odds_would_hit_top_row(self, draw, text: str, placements) -> bool: + """Whether any odds text overlaps the centred text on the top row. Baseball is the only scoreboard with something centred on that row, so it is the only one that can collide. Deciding by measurement rather than by panel size keeps the rule honest: what matters is whether these particular strings fit beside each other, and a two-digit over/under is several pixels wider than a one-digit one. + + The text is passed in rather than derived, because the three cards do + not draw the same thing there: a live card shows the inning, a recent + card "Final", and an upcoming card a time or a date. Measuring the + inning for all three checked a string that was not on the panel. """ - text = self._inning_text(game) if not text: return False try: - inning_font = self.fonts['time'] - inning_width = draw.textbbox((0, 0), text, font=inning_font)[2] + top_font = self.fonts['time'] + inning_width = draw.textbbox((0, 0), text, font=top_font)[2] except Exception: return False left = (self.display_width - inning_width) // 2 @@ -1032,12 +1055,16 @@ def _odds_would_hit_inning(self, draw, game: Optional[Dict], placements) -> bool return any(x < right + 1 and x + width > left - 1 for _text, x, width in placements) - def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None) -> None: + def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None, + top_text: Optional[str] = None) -> None: """Draw odds with dynamic positioning based on favored team. - `game` is optional and used only to measure the centred inning text, - so the odds can step down a row on a panel too narrow to fit beside - it. Omitting it simply skips that check. + `top_text` is whatever this card centres on the top row, so the odds + can step down a row on a panel too narrow to fit beside it. Callers + should pass what they actually draw -- "Final" on a recent card, the + time or date on an upcoming one. When omitted it falls back to the + live card's inning indicator, which is what `game` is for; passing + neither simply skips the check. """ try: if not odds: @@ -1112,7 +1139,8 @@ def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None) -> N return odds_y = 0 + odds_y_offset - if self._odds_would_hit_inning(draw, game, placements): + obstacle = top_text if top_text is not None else self._inning_text(game) + if self._odds_would_hit_top_row(draw, obstacle, placements): # Step down one text row, which is where these used to live # unconditionally. Measured rather than keyed to a panel size: # it is the text widths that decide, and a two-digit over/under diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 73fafe65..b679dda1 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.24.1", + "version": "1.24.2", "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": [ + { + "released": "2026-08-12", + "version": "1.24.2", + "ledmatrix_min_version": "2.0.0", + "notes": "Measure the top-row text each card actually draws before deciding whether the odds must step down a row. The check always measured the live card's inning indicator, but a recent card centres \"Final\" there and an upcoming card a time or date, so on narrow panels it compared against a string that was not on the screen." + }, { "version": "1.24.1", "released": "2026-08-11", @@ -161,7 +167,7 @@ { "released": "2026-07-08", "version": "1.14.2", - "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team \u25b2/\u25bc indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", + "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team ▲/▼ indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", "ledmatrix_min": "2.0.0" }, { @@ -179,7 +185,7 @@ { "released": "2026-07-07", "version": "1.13.1", - "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team \u25b2/\u25bc arrow, which wasn't factored into the width check at all.", + "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team ▲/▼ arrow, which wasn't factored into the width check at all.", "ledmatrix_min": "2.0.0" }, { @@ -221,7 +227,7 @@ { "released": "2026-07-02", "version": "1.7.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/baseball-scoreboard/test_odds_placement.py b/plugins/baseball-scoreboard/test_odds_placement.py index 13b3740d..1dddb798 100644 --- a/plugins/baseball-scoreboard/test_odds_placement.py +++ b/plugins/baseball-scoreboard/test_odds_placement.py @@ -59,7 +59,7 @@ def check(name, cond, detail=""): def odds_y(width, height, over_under, inning_half="top", inning=3, - y_offset=0, x_offset=0): + y_offset=0, x_offset=0, top_text=None): """Render the odds and report the y they landed on.""" r = gr.GameRenderer.__new__(gr.GameRenderer) r.display_width, r.display_height = width, height @@ -78,7 +78,7 @@ def odds_y(width, height, over_under, inning_half="top", inning=3, "spread": -1.5, "over_under": over_under, "home_team_odds": {"spread_odds": -1.5}, "away_team_odds": {"spread_odds": 1.5}, - }, game=game) + }, game=game, top_text=top_text) if not drawn: return None, drawn return max(y for _t, _x, y in drawn), drawn @@ -108,6 +108,30 @@ def main(): check("a narrow panel with short strings still uses the top edge", y_final == 0, "y=%s" % y_final) + print("\neach card type is measured against the text it actually draws") + # _draw_dynamic_odds is called from the live, recent and upcoming + # renderers, and they do not centre the same string on the top row: the + # inning, "Final", and a time or date respectively. Measuring the inning + # for all three checked a string that was not on the panel. + y_live, _ = odds_y(64, 32, 12.5) + y_final, _ = odds_y(64, 32, 12.5, top_text="Final") + check("a wide O/U still steps down beside the inning", y_live > 0, + "y=%s" % y_live) + check("and beside a recent card's 'Final'", y_final > 0, "y=%s" % y_final) + + y_time, _ = odds_y(64, 32, 12.5, top_text="7:05 PM") + check("and beside an upcoming card's time", y_time > 0, "y=%s" % y_time) + + # Nothing centred on the top row means nothing to avoid. + y_clear, _ = odds_y(64, 32, 12.5, top_text="") + check("but an empty top row leaves the odds at the edge", y_clear == 0, + "y=%s" % y_clear) + + # A wide panel has room beside any of them. + for label, t in (("inning", None), ("Final", "Final"), ("time", "7:05 PM")): + y, _ = odds_y(256, 64, 12.5, top_text=t) + check("256x64 clears the %s" % label, y == 0, "y=%s" % y) + print("\nthe manual offsets still apply") y0, _ = odds_y(256, 64, 8.5) y5, _ = odds_y(256, 64, 8.5, y_offset=5) diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index 086ab003..ac9cd483 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -878,16 +878,17 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None favored_spread = away_spread favored_side = "away" + # Read once, before either branch. These used to be read inside + # the spread branch, but the over/under below uses them too -- so + # a game with a total and no spread raised UnboundLocalError, and + # the except swallowed it, drawing no odds at all. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + # Show the negative spread if favored_spread is not None: spread_text = str(favored_spread) font = self.fonts["detail"] - - # Odds had no layout control at all here, while baseball, - # basketball, football and ufc all expose one. Same element, - # same card, so it gets the same knob. - odds_x_offset = self._layout_offset('odds', 'x_offset') - odds_y_offset = self._layout_offset('odds', 'y_offset') if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 924561af..6cd001dd 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.6.0", + "version": "1.6.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-08-12", + "version": "1.6.1", + "ledmatrix_min_version": "2.0.0", + "notes": "Draw the odds when a game is priced with a total and no spread. The layout offsets were read inside the spread branch while the over/under below uses them too, so such a game raised UnboundLocalError and the surrounding except swallowed it -- the card drew no odds at all." + }, { "version": "1.6.0", "released": "2026-08-11", diff --git a/plugins/nrl-scoreboard/test_odds_without_spread.py b/plugins/nrl-scoreboard/test_odds_without_spread.py new file mode 100644 index 00000000..9c5c3dd6 --- /dev/null +++ b/plugins/nrl-scoreboard/test_odds_without_spread.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Tests that odds still draw when a game has a total but no spread. + +The layout offsets used to be read inside `if favored_spread is not None`, +while the over/under block below uses them unconditionally. A game priced +with a total and no spread -- ordinary enough -- therefore raised +UnboundLocalError, and the bare `except` around the whole method swallowed +it, so the card drew no odds at all rather than drawing the total. + +Run: /bin/python plugins/nrl-scoreboard/test_odds_without_spread.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import game_renderer as gr # noqa: E402 + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def _renderer(drawn): + r = gr.GameRenderer.__new__(gr.GameRenderer) + r.display_width, r.display_height = 128, 64 + r.config = {} + r.logger = type("L", (), {m: (lambda *a, **k: None) + for m in ("exception", "error", "warning", "debug", "info")})() + font = ImageFont.load_default() + r.fonts = {"detail": font, "time": font, "score": font, "team": font} + r._layout_offset = lambda e, a, default=0: 0 + r._draw_text_with_outline = ( + lambda draw, t, xy, font, fill=None: drawn.append(t)) + return r + + +def main(): + draw = ImageDraw.Draw(Image.new("RGB", (128, 64))) + + print("a total with no spread still draws") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, {"over_under": 47.5}) + check("the over/under reached the panel", any("47.5" in t for t in drawn), + "drew %r" % drawn) + + print("\nand the ordinary case is unchanged") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, { + "spread": -3.5, "over_under": 47.5, + "home_team_odds": {"spread_odds": -3.5}, + "away_team_odds": {"spread_odds": 3.5}, + }) + check("both spread and total drew", len(drawn) >= 2, "drew %r" % drawn) + + print("\na spread with no total still draws") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, { + "spread": -3.5, + "home_team_odds": {"spread_odds": -3.5}, + "away_team_odds": {"spread_odds": 3.5}, + }) + check("the spread reached the panel", any("3.5" in t for t in drawn), + "drew %r" % drawn) + + print("\nempty odds draw nothing") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, {}) + check("nothing drawn", not drawn, "drew %r" % drawn) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index ec7c902b..473634ea 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -878,16 +878,17 @@ def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None favored_spread = away_spread favored_side = "away" + # Read once, before either branch. These used to be read inside + # the spread branch, but the over/under below uses them too -- so + # a game with a total and no spread raised UnboundLocalError, and + # the except swallowed it, drawing no odds at all. + odds_x_offset = self._layout_offset('odds', 'x_offset') + odds_y_offset = self._layout_offset('odds', 'y_offset') + # Show the negative spread if favored_spread is not None: spread_text = str(favored_spread) font = self.fonts["detail"] - - # Odds had no layout control at all here, while baseball, - # basketball, football and ufc all expose one. Same element, - # same card, so it gets the same knob. - odds_x_offset = self._layout_offset('odds', 'x_offset') - odds_y_offset = self._layout_offset('odds', 'y_offset') if favored_side == "home": spread_width = draw.textlength(spread_text, font=font) diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index f9c6be2c..f3de7cbb 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.9.0", + "version": "2.9.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-08-12", + "version": "2.9.1", + "ledmatrix_min_version": "2.0.0", + "notes": "Draw the odds when a game is priced with a total and no spread. The layout offsets were read inside the spread branch while the over/under below uses them too, so such a game raised UnboundLocalError and the surrounding except swallowed it -- the card drew no odds at all." + }, { "version": "2.9.0", "released": "2026-08-11", @@ -81,7 +87,7 @@ { "released": "2026-07-29", "version": "2.5.0", - "notes": "Corrected every team code in TEAMS.md against ESPN's live data \u2014 Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", + "notes": "Corrected every team code in TEAMS.md against ESPN's live data — Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", "ledmatrix_min": "2.0.0" }, { @@ -105,7 +111,7 @@ { "released": "2026-07-02", "version": "2.2.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/soccer-scoreboard/test_odds_without_spread.py b/plugins/soccer-scoreboard/test_odds_without_spread.py new file mode 100644 index 00000000..fae4bcaa --- /dev/null +++ b/plugins/soccer-scoreboard/test_odds_without_spread.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Tests that odds still draw when a game has a total but no spread. + +The layout offsets used to be read inside `if favored_spread is not None`, +while the over/under block below uses them unconditionally. A game priced +with a total and no spread -- ordinary enough -- therefore raised +UnboundLocalError, and the bare `except` around the whole method swallowed +it, so the card drew no odds at all rather than drawing the total. + +Run: /bin/python plugins/soccer-scoreboard/test_odds_without_spread.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import game_renderer as gr # noqa: E402 + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def _renderer(drawn): + r = gr.GameRenderer.__new__(gr.GameRenderer) + r.display_width, r.display_height = 128, 64 + r.config = {} + r.logger = type("L", (), {m: (lambda *a, **k: None) + for m in ("exception", "error", "warning", "debug", "info")})() + font = ImageFont.load_default() + r.fonts = {"detail": font, "time": font, "score": font, "team": font} + r._layout_offset = lambda e, a, default=0: 0 + r._draw_text_with_outline = ( + lambda draw, t, xy, font, fill=None: drawn.append(t)) + return r + + +def main(): + draw = ImageDraw.Draw(Image.new("RGB", (128, 64))) + + print("a total with no spread still draws") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, {"over_under": 47.5}) + check("the over/under reached the panel", any("47.5" in t for t in drawn), + "drew %r" % drawn) + + print("\nand the ordinary case is unchanged") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, { + "spread": -3.5, "over_under": 47.5, + "home_team_odds": {"spread_odds": -3.5}, + "away_team_odds": {"spread_odds": 3.5}, + }) + check("both spread and total drew", len(drawn) >= 2, "drew %r" % drawn) + + print("\na spread with no total still draws") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, { + "spread": -3.5, + "home_team_odds": {"spread_odds": -3.5}, + "away_team_odds": {"spread_odds": 3.5}, + }) + check("the spread reached the panel", any("3.5" in t for t in drawn), + "drew %r" % drawn) + + print("\nempty odds draw nothing") + drawn = [] + _renderer(drawn)._draw_dynamic_odds(draw, {}) + check("nothing drawn", not drawn, "drew %r" % drawn) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From a460d98da55aa01aed0b560670bf3652eb329f66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:22:49 -0400 Subject: [PATCH 4/6] style(baseball): name the optional odds parameters at every call site Codacy resolves the bare `game_renderer` module name across plugins, so it can bind another scoreboard's copy -- whose _draw_dynamic_odds takes fewer arguments -- and reports the extra positional ones as an error. It flagged the two call sites this branch changed, the same complaint that was already settled once on the test file. Naming the optional parameters makes the calls unambiguous to a reader and to the checker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/baseball-scoreboard/game_renderer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 05efc944..9833f0fb 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -531,7 +531,7 @@ def _render_live_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game) + self._draw_dynamic_odds(draw, game['odds'], game=game) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -587,7 +587,7 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game, top_text="Final") + self._draw_dynamic_odds(draw, game['odds'], game=game, top_text="Final") main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -938,7 +938,7 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game, + self._draw_dynamic_odds(draw, game['odds'], game=game, top_text=self._upcoming_top_row_text(upcoming)) main_img = Image.alpha_composite(main_img, overlay) From f6e5ab2678e7b9ac103fb01360bcb678dc1c6f4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:31:03 -0400 Subject: [PATCH 5/6] style(sports): keep the new tests' imports where checkers expect them The afl test acquired a second module-level import block partway down the file, for the core-path shim its renderer needs, which reads as two misplaced imports. os moves up with the other standard-library imports; the plugin import has to stay after the sys.path work and now says so explicitly rather than leaving a checker to guess. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/afl-scoreboard/test_odds_without_spread.py | 4 ++-- plugins/nrl-scoreboard/test_odds_without_spread.py | 2 +- plugins/soccer-scoreboard/test_odds_without_spread.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/afl-scoreboard/test_odds_without_spread.py b/plugins/afl-scoreboard/test_odds_without_spread.py index 98e04418..d45885aa 100644 --- a/plugins/afl-scoreboard/test_odds_without_spread.py +++ b/plugins/afl-scoreboard/test_odds_without_spread.py @@ -10,6 +10,7 @@ Run: /bin/python plugins/afl-scoreboard/test_odds_without_spread.py """ +import os import sys from pathlib import Path @@ -18,7 +19,6 @@ # This renderer imports src.logo_downloader at module scope, so the core tree # has to be importable. LEDMATRIX_CORE points at a checkout. -import os _core = os.environ.get('LEDMATRIX_CORE', '') for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): if _candidate and (Path(_candidate) / 'src').is_dir(): @@ -34,7 +34,7 @@ print("SKIP: Pillow not installed") sys.exit(2) -import game_renderer as gr # noqa: E402 +import game_renderer as gr # noqa: E402 # pylint: disable=wrong-import-position failures = [] diff --git a/plugins/nrl-scoreboard/test_odds_without_spread.py b/plugins/nrl-scoreboard/test_odds_without_spread.py index 9c5c3dd6..7d1bcd20 100644 --- a/plugins/nrl-scoreboard/test_odds_without_spread.py +++ b/plugins/nrl-scoreboard/test_odds_without_spread.py @@ -22,7 +22,7 @@ print("SKIP: Pillow not installed") sys.exit(2) -import game_renderer as gr # noqa: E402 +import game_renderer as gr # noqa: E402 # pylint: disable=wrong-import-position failures = [] diff --git a/plugins/soccer-scoreboard/test_odds_without_spread.py b/plugins/soccer-scoreboard/test_odds_without_spread.py index fae4bcaa..442beae0 100644 --- a/plugins/soccer-scoreboard/test_odds_without_spread.py +++ b/plugins/soccer-scoreboard/test_odds_without_spread.py @@ -22,7 +22,7 @@ print("SKIP: Pillow not installed") sys.exit(2) -import game_renderer as gr # noqa: E402 +import game_renderer as gr # noqa: E402 # pylint: disable=wrong-import-position failures = [] From 2db4bba4027190598d15f32e5e19fd8d806d1f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:37:02 -0400 Subject: [PATCH 6/6] fix(baseball): measure the top row as drawn, not as re-derived The collision check took the top-row text and measured it itself, always with the `time` font and always perfectly centred. An upcoming card with swap_date_time draws the date in `detail` instead, shifted by that element's configured x_offset, so the check could move the odds down when nothing was in the way or leave them overlapping when something was. Reported by CodeRabbit. Callers now pass the span they actually drew. That also removes the last place where the measurement could drift from the drawing, which is the same defect this file already had once: the check used to measure a live card's inning on recent and upcoming cards too. "Nothing on the top row" and "the caller did not say" were both None, so a card that genuinely centres nothing fell back to measuring an inning it does not draw. A sentinel separates them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 103 ++++++++++++------ plugins/baseball-scoreboard/manifest.json | 8 +- .../test_odds_placement.py | 42 ++++++- 4 files changed, 117 insertions(+), 38 deletions(-) diff --git a/plugins.json b/plugins.json index 5a9f1c05..940859c7 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.24.2" + "latest_version": "1.24.3" }, { "id": "basketball-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 9833f0fb..2ddf3728 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -39,6 +39,12 @@ def resolve_font_name(font_name: str) -> str: RESAMPLE_FILTER = Image.LANCZOS +# Distinguishes "the caller did not say" from "the caller says the top row is +# empty". Both were None, so a card that genuinely centres nothing up there +# fell back to measuring an inning it does not draw. +_DERIVE_TOP_SPAN = object() + + class GameRenderer: """Renders individual baseball game cards as PIL Images.""" @@ -531,7 +537,9 @@ def _render_live_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game=game) + self._draw_dynamic_odds( + draw, game['odds'], game=game, + top_span=self._top_row_span(draw, inning_text, inning_font)) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -587,7 +595,9 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game=game, top_text="Final") + self._draw_dynamic_odds( + draw, game['odds'], game=game, + top_span=self._top_row_span(draw, "Final", self.fonts['time'])) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -824,18 +834,6 @@ def _upcoming_date_time_texts(self, game: Dict): if self._scroll_card_option("show_time", True) else "") return date_text, time_text - def _upcoming_top_row_text(self, game: Dict) -> str: - """Whatever an upcoming card centres on its top row, or "". - - Read by the odds collision check, which has to measure the string that - is actually on the panel. Derived here rather than in the caller so it - cannot drift from _draw_upcoming_game_status, which makes the same - choice a few lines below. - """ - if self._upcoming_center_mode() == "date_time": - return "" # both are stacked in the middle; the top row is free - date_text, time_text = self._upcoming_date_time_texts(game) - return date_text if self._scroll_card_option("swap_date_time", False) else time_text def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: """Draw the date and time around an upcoming card. @@ -938,8 +936,9 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Odds if game.get('odds'): - self._draw_dynamic_odds(draw, game['odds'], game=game, - top_text=self._upcoming_top_row_text(upcoming)) + self._draw_dynamic_odds( + draw, game['odds'], game=game, + top_span=self._upcoming_top_row_span(draw, upcoming)) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -1028,7 +1027,43 @@ def _inning_text(self, game: Optional[Dict]) -> str: symbol = "\u25b2" if inning_half == 'top' else "\u25bc" return f"{symbol}{inning_num}" - def _odds_would_hit_top_row(self, draw, text: str, placements) -> bool: + def _top_row_span(self, draw, text: str, font, x_offset: int = 0): + """The horizontal span a centred top-row string occupies, or None. + + Mirrors how the cards place that text: centred, then nudged by the + element's configured x_offset. Returned as pixels rather than derived + from the text again later, because the three cards do not agree on the + font either -- an upcoming card with swap_date_time draws the date in + `detail`, not `time`. + """ + if not text: + return None + try: + width = draw.textlength(text, font=font) + except Exception: + return None + left = int((self.display_width - width) // 2 + x_offset) + return left, int(left + width) + + def _upcoming_top_row_span(self, draw, game: Dict): + """The span an upcoming card's top-row text occupies, or None. + + Font and offset are chosen exactly as _draw_upcoming_game_status does, + so the odds are measured against what is really on the panel. + """ + if self._upcoming_center_mode() == "date_time": + return None # both are stacked in the middle; the top row is free + date_text, time_text = self._upcoming_date_time_texts(game) + if self._scroll_card_option("swap_date_time", False): + text, element = date_text, 'date' + font = self.fonts.get('detail') or self.fonts['time'] + else: + text, element = time_text, 'time' + font = self.fonts['time'] + return self._top_row_span(draw, text, font, + self._layout_offset(element, 'x_offset')) + + def _odds_would_hit_top_row(self, span, placements) -> bool: """Whether any odds text overlaps the centred text on the top row. Baseball is the only scoreboard with something centred on that row, so @@ -1042,29 +1077,26 @@ def _odds_would_hit_top_row(self, draw, text: str, placements) -> bool: card "Final", and an upcoming card a time or a date. Measuring the inning for all three checked a string that was not on the panel. """ - if not text: - return False - try: - top_font = self.fonts['time'] - inning_width = draw.textbbox((0, 0), text, font=top_font)[2] - except Exception: + if not span: return False - left = (self.display_width - inning_width) // 2 - right = left + inning_width + left, right = span # One pixel of breathing room either side, so glyphs do not touch. return any(x < right + 1 and x + width > left - 1 for _text, x, width in placements) def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None, - top_text: Optional[str] = None) -> None: + top_span=_DERIVE_TOP_SPAN) -> None: """Draw odds with dynamic positioning based on favored team. - `top_text` is whatever this card centres on the top row, so the odds - can step down a row on a panel too narrow to fit beside it. Callers - should pass what they actually draw -- "Final" on a recent card, the - time or date on an upcoming one. When omitted it falls back to the - live card's inning indicator, which is what `game` is for; passing - neither simply skips the check. + `top_span` is the (left, right) the card's own top-row text occupies, + so the odds can step down a row when they would not fit beside it. + Callers pass the span they actually drew rather than a string, because + the three cards agree on neither the text, the font, nor the centring: + a recent card draws "Final" in `time`, an upcoming card a time in + `time` or -- with swap_date_time -- a date in `detail`, shifted by + that element's configured x_offset. When omitted it falls back to + measuring the live card's inning indicator, which is what `game` is + for; passing neither simply skips the check. """ try: if not odds: @@ -1139,8 +1171,11 @@ def _draw_dynamic_odds(self, draw, odds: Dict, game: Optional[Dict] = None, return odds_y = 0 + odds_y_offset - obstacle = top_text if top_text is not None else self._inning_text(game) - if self._odds_would_hit_top_row(draw, obstacle, placements): + obstacle = top_span + if obstacle is _DERIVE_TOP_SPAN: + obstacle = self._top_row_span( + draw, self._inning_text(game), self.fonts['time']) + if self._odds_would_hit_top_row(obstacle, placements): # Step down one text row, which is where these used to live # unconditionally. Measured rather than keyed to a panel size: # it is the text widths that decide, and a two-digit over/under diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index b679dda1..81272ce2 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.24.2", + "version": "1.24.3", "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.24.3", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Measure the top row by the span the card actually draws, not by a re-derived string. The check assumed one font and pure centring, but an upcoming card with swap_date_time draws the date in `detail` rather than `time` and shifts it by that element's x_offset, so the odds could be moved down when nothing was in the way or left overlapping when something was." + }, { "released": "2026-08-12", "version": "1.24.2", diff --git a/plugins/baseball-scoreboard/test_odds_placement.py b/plugins/baseball-scoreboard/test_odds_placement.py index 1dddb798..a7612e00 100644 --- a/plugins/baseball-scoreboard/test_odds_placement.py +++ b/plugins/baseball-scoreboard/test_odds_placement.py @@ -47,6 +47,17 @@ def _font(): FONT = _font() + + +def _font_at(size): + """The core's 4x6 at a given size, or PIL's default.""" + import os + core = os.environ.get("LEDMATRIX_CORE", "") + for base in (core, "."): + p = Path(base) / "assets" / "fonts" / "4x6-font.ttf" + if p.exists(): + return ImageFont.truetype(str(p), size) + return ImageFont.load_default() failures = [] @@ -59,7 +70,8 @@ def check(name, cond, detail=""): def odds_y(width, height, over_under, inning_half="top", inning=3, - y_offset=0, x_offset=0, top_text=None): + y_offset=0, x_offset=0, top_text=None, top_font=None, + top_x_offset=0): """Render the odds and report the y they landed on.""" r = gr.GameRenderer.__new__(gr.GameRenderer) r.display_width, r.display_height = width, height @@ -78,7 +90,8 @@ def odds_y(width, height, over_under, inning_half="top", inning=3, "spread": -1.5, "over_under": over_under, "home_team_odds": {"spread_odds": -1.5}, "away_team_odds": {"spread_odds": 1.5}, - }, game=game, top_text=top_text) + }, game=game, **({} if top_text is None else {'top_span': + r._top_row_span(draw, top_text, top_font or FONT, top_x_offset)})) if not drawn: return None, drawn return max(y for _t, _x, y in drawn), drawn @@ -132,6 +145,31 @@ def main(): y, _ = odds_y(256, 64, 12.5, top_text=t) check("256x64 clears the %s" % label, y == 0, "y=%s" % y) + print("\nthe span is measured with the font and offset actually used") + # An upcoming card with swap_date_time draws the date in `detail`, not + # `time`, and shifts it by that element's x_offset. Measuring a centred + # `time` string instead can both miss a real overlap and invent one. + r = gr.GameRenderer.__new__(gr.GameRenderer) + r.display_width, r.display_height = 128, 32 + d = ImageDraw.Draw(Image.new("RGB", (128, 32))) + + small = r._top_row_span(d, "Sep 19", FONT) + big = r._top_row_span(d, "Sep 19", _font_at(10)) + check("a wider font gives a wider span", + (big[1] - big[0]) > (small[1] - small[0]), + "%r vs %r" % (big, small)) + # Within a couple of pixels: the width is fractional and both ends are + # truncated to int, so the midpoint can sit just under centre. + check("and both stay centred", abs((small[0] + small[1]) - 128) <= 2 + and abs((big[0] + big[1]) - 128) <= 2, "%r %r" % (small, big)) + + shifted = r._top_row_span(d, "Sep 19", FONT, x_offset=-20) + check("an x_offset moves the span with the text", + shifted[0] == small[0] - 20 and shifted[1] == small[1] - 20, + "%r vs %r" % (shifted, small)) + + check("an empty top row has no span", r._top_row_span(d, "", FONT) is None) + print("\nthe manual offsets still apply") y0, _ = odds_y(256, 64, 8.5) y5, _ = odds_y(256, 64, 8.5, y_offset=5)