diff --git a/plugins.json b/plugins.json index bac54eae..940859c7 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.3" }, { "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.1" }, { "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.1", "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.1" }, { "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..1c208bbe 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -903,18 +903,24 @@ 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"] - + 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 +932,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..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.5.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,18 @@ "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", + "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/afl-scoreboard/test_odds_without_spread.py b/plugins/afl-scoreboard/test_odds_without_spread.py new file mode 100644 index 00000000..d45885aa --- /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 os +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. +_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 # pylint: disable=wrong-import-position + +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 99071a82..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']) + 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']) + 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") @@ -815,6 +825,16 @@ 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 _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: """Draw the date and time around an upcoming card. @@ -825,11 +845,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 +936,9 @@ 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=game, + top_span=self._upcoming_top_row_span(draw, upcoming)) main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -994,8 +1012,92 @@ 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 _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 + 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. + """ + if not span: + return False + 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_span=_DERIVE_TOP_SPAN) -> None: + """Draw odds with dynamic positioning based on favored team. + + `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: return @@ -1031,12 +1133,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 +1153,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 +1165,27 @@ 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 + 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 + # ("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..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.0", + "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,24 @@ "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", + "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", + "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", @@ -155,7 +173,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" }, { @@ -173,7 +191,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" }, { @@ -215,7 +233,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 new file mode 100644 index 00000000..a7612e00 --- /dev/null +++ b/plugins/baseball-scoreboard/test_odds_placement.py @@ -0,0 +1,202 @@ +#!/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() + + +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 = [] + + +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, 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 + 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=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 + + +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("\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 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) + 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))), {}, game={}) + 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..ac9cd483 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -878,18 +878,24 @@ 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"] - + 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 +907,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..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.5.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,18 @@ "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", + "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/nrl-scoreboard/test_odds_without_spread.py b/plugins/nrl-scoreboard/test_odds_without_spread.py new file mode 100644 index 00000000..7d1bcd20 --- /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 # pylint: disable=wrong-import-position + +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/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..473634ea 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -878,18 +878,24 @@ 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"] - + 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 +907,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..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.8.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,18 @@ "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", + "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", @@ -75,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" }, { @@ -99,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..442beae0 --- /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 # pylint: disable=wrong-import-position + +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/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)" )