diff --git a/plugins.json b/plugins.json index 11a4eaf..1e68680 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-04-09", + "last_updated": "2026-04-10", "plugins": [ { "id": "hello-world", @@ -699,10 +699,10 @@ "plugin_path": "plugins/masters-tournament", "stars": 0, "downloads": 0, - "last_updated": "2026-04-09", + "last_updated": "2026-04-10", "verified": true, "screenshot": "", - "latest_version": "2.2.7" + "latest_version": "2.3.1" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index 4fff105..4ea9563 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -9,7 +9,7 @@ import logging import os import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from PIL import Image @@ -21,8 +21,10 @@ from masters_renderer_enhanced import MastersRendererEnhanced from logo_loader import MastersLogoLoader from masters_helpers import ( + _masters_thursday, calculate_tournament_countdown, filter_favorite_players, + format_score_to_par, get_detailed_phase, get_tournament_phase, sort_leaderboard, @@ -104,7 +106,8 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self._current_display_mode: Optional[str] = None # Course tour state (separate cursors so modes don't interfere) - self._current_hole = 1 + self._current_hole = 1 # used by masters_course_tour + self._hole_by_hole_index = 1 # used by masters_hole_by_hole (independent) self._featured_hole_index = 0 # Pagination state for each mode (auto-advances each display cycle) @@ -125,6 +128,8 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self._hole_switch_interval = config.get("hole_display_duration", 15) self._last_fact_advance = 0 self._fact_advance_interval = 2 # seconds between scroll steps + self._last_fact_change = 0.0 # when the current fact started showing + self._fact_dwell = config.get("fun_fact_duration", 20) # seconds per fact self._last_page_advance = {} # per-mode page timers self._page_interval = config.get("page_display_duration", 15) @@ -266,10 +271,20 @@ def _build_enabled_modes(self) -> List[str]: enabled = [] for mode in phase_modes: config_key = config_key_map.get(mode) - if config_key: - mode_config = display_modes_config.get(config_key, {}) + if not config_key: + continue + mode_config = display_modes_config.get(config_key) + if mode_config is None: + # Not configured → enabled by default + enabled.append(mode) + elif isinstance(mode_config, bool): + # Web UI may save "fun_facts": false instead of nested {"enabled": false} + if mode_config: + enabled.append(mode) + elif isinstance(mode_config, dict): if mode_config.get("enabled", True): enabled.append(mode) + # else: unexpected type → treat as disabled self.logger.debug(f"Phase '{phase}' -> {len(enabled)} modes: {enabled}") return enabled @@ -461,8 +476,20 @@ def _display_past_champions(self, force_clear: bool) -> bool: return self._show_image(self.renderer.render_past_champions(page=page)) def _display_hole_by_hole(self, force_clear: bool) -> bool: - """Display hole-by-hole course tour (same as course_tour).""" - return self._display_course_tour(force_clear) + """Display hole-by-hole course tour with its own independent hole cursor. + + Uses a separate index and timer from _display_course_tour so that when + both modes appear in the same phase rotation they don't share state and + double-advance the hole counter. + """ + now = time.time() + last = self._last_hole_advance.get("hole_by_hole", 0) + if last > 0 and now - last >= self._hole_switch_interval: + self._hole_by_hole_index = (self._hole_by_hole_index % 18) + 1 + self._last_hole_advance["hole_by_hole"] = now + elif last == 0: + self._last_hole_advance["hole_by_hole"] = now + return self._show_image(self.renderer.render_hole_card(self._hole_by_hole_index)) def _display_featured_holes(self, force_clear: bool) -> bool: featured = [12, 13, 15, 16] @@ -485,13 +512,13 @@ def _display_schedule(self, force_clear: bool) -> bool: def _display_live_action(self, force_clear: bool) -> bool: """Show live alert if enhanced renderer available, else leaderboard.""" if hasattr(self.renderer, "render_live_alert") and self._leaderboard_data: - # Show the leader's current status as a live alert leader = self._leaderboard_data[0] + score_label = format_score_to_par(leader.get("score")) return self._show_image( self.renderer.render_live_alert( leader.get("player", ""), leader.get("current_hole", 18) or 18, - "Leader", + score_label, ) ) return self._display_leaderboard(force_clear) @@ -505,13 +532,22 @@ def _display_fun_facts(self, force_clear: bool) -> bool: self.renderer.render_fun_fact(self._fact_index, scroll_offset=self._fact_scroll) ) now = time.time() + # Initialise dwell timer on first call. + if self._last_fact_change == 0.0: + self._last_fact_change = now + # Advance scroll offset every _fact_advance_interval seconds so long + # facts scroll through all their lines. The renderer wraps scroll_offset + # via modulo so it cycles cleanly without any reset needed here. if now - self._last_fact_advance >= self._fact_advance_interval: self._fact_scroll += 1 self._last_fact_advance = now - # Move to next fact after scrolling through - if self._fact_scroll > 5: + # Move to the next fact only after the full dwell period has elapsed, + # giving enough time for all lines to scroll into view regardless of + # how many wrapped lines the fact produces. + if now - self._last_fact_change >= self._fact_dwell: self._fact_index += 1 self._fact_scroll = 0 + self._last_fact_change = now return result def _display_countdown(self, force_clear: bool) -> bool: @@ -523,8 +559,8 @@ def _display_countdown(self, force_clear: bool) -> bool: target = meta["start_date"] else: # Hard fallback — should be unreachable, but keep the screen alive. - now = datetime.utcnow() - target = datetime(now.year, 4, 10, 12, 0, 0) + now = datetime.now(timezone.utc) + target = _masters_thursday(now.year) countdown = calculate_tournament_countdown(target) return self._show_image( self.renderer.render_countdown( @@ -610,8 +646,10 @@ def on_config_change(self, new_config): self._page_interval = new_config.get("page_display_duration", 15) self._player_card_interval = new_config.get("player_card_duration", 8) self._scroll_card_width = new_config.get("scroll_card_width", 128) + self._fact_dwell = new_config.get("fun_fact_duration", 20) self._last_hole_advance.clear() self._last_page_advance.clear() + self._last_fact_change = 0.0 self.modes = self._build_enabled_modes() self._last_update = 0 diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index ba05e19..edd0c13 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.7", + "version": "2.3.1", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,26 @@ "height": 64 }, "versions": [ + { + "version": "2.3.1", + "released": "2026-04-10", + "ledmatrix_min_version": "2.0.0" + }, + { + "version": "2.3.0", + "released": "2026-04-10", + "ledmatrix_min_version": "2.0.0" + }, + { + "version": "2.2.9", + "released": "2026-04-10", + "ledmatrix_min_version": "2.0.0" + }, + { + "version": "2.2.8", + "released": "2026-04-10", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.2.7", "released": "2026-04-09", @@ -104,7 +124,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-04-09", + "last_updated": "2026-04-10", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py index 9367028..5d85f36 100644 --- a/plugins/masters-tournament/masters_data.py +++ b/plugins/masters-tournament/masters_data.py @@ -13,7 +13,7 @@ import requests -from masters_helpers import ESPN_HEADSHOT_URL, ESPN_PLAYER_IDS, get_espn_headshot_url, get_player_country +from masters_helpers import ESPN_HEADSHOT_URL, ESPN_PLAYER_IDS, _masters_thursday, get_espn_headshot_url, get_player_country logger = logging.getLogger(__name__) @@ -267,15 +267,15 @@ def _format_tee_time_et(cls, iso_value: Optional[str]) -> str: return f"{display_hour}:{minute:02d} {suffix}" def _computed_fallback_meta(self) -> Dict: - """Compute a best-guess Masters window: second Thursday of April. + """Compute a best-guess Masters window using the April 6-12 Thursday rule. Used only when ESPN doesn't currently return the Masters (off-season). """ now = datetime.now(timezone.utc) year = now.year - start = self._second_thursday_of_april(year) + start = _masters_thursday(year) if now > start + timedelta(days=4): - start = self._second_thursday_of_april(year + 1) + start = _masters_thursday(year + 1) # Cover all four calendar days (Thu–Sun) through end-of-day, matching # the normalization applied to ESPN's parsed endDate. end = start + timedelta(days=3, hours=23, minutes=59, seconds=59) @@ -290,12 +290,8 @@ def _computed_fallback_meta(self) -> Dict: @staticmethod def _second_thursday_of_april(year: int) -> datetime: - """Second Thursday of April at 12:00 UTC (≈ 8am ET tee-off).""" - d = datetime(year, 4, 1, 12, 0, 0, tzinfo=timezone.utc) - # weekday(): Mon=0 … Thu=3 - days_to_first_thursday = (3 - d.weekday()) % 7 - first_thursday = d + timedelta(days=days_to_first_thursday) - return first_thursday + timedelta(days=7) + """Alias kept for backwards compatibility — delegates to _masters_thursday.""" + return _masters_thursday(year) # ── Schedule / tee times ───────────────────────────────────── @@ -615,6 +611,7 @@ def _parse_leaderboard(self, data: Dict) -> List[Dict]: "current_hole": status.get("hole"), "status": status.get("displayValue", ""), "tee_time": status.get("teeTime"), + "is_active": self._is_active_competitor(entry), }) except Exception as e: @@ -622,19 +619,39 @@ def _parse_leaderboard(self, data: Dict) -> List[Dict]: return players - def _calculate_score_to_par(self, entry: Dict) -> int: - """Calculate player's score relative to par.""" + # ESPN values that indicate a player is no longer competing (missed cut, + # withdrawal, disqualification). Returning 0 for these would misrepresent + # them as even par; callers should check `is_active` on the player dict. + _INACTIVE_SCORE_VALUES = frozenset({"MC", "WD", "DQ", "CUT", "MDF", "--"}) + + def _calculate_score_to_par(self, entry: Dict) -> Optional[int]: + """Calculate player's score relative to par. + + Returns None for inactive players (MC/WD/DQ/CUT/MDF/--) and for + unknown/unparseable values so downstream renderers can show "--" + rather than treating them as even par. + Returns 0 for "E" (even par) and an int for numeric scores. + """ try: - display_value = (entry.get("score") or {}).get("displayValue", "E") - if not display_value or display_value in ("-", "E"): + display_value = (entry.get("score") or {}).get("displayValue", "") + if not display_value or display_value == "-": + return None + if display_value == "E": return 0 + if display_value.upper() in self._INACTIVE_SCORE_VALUES: + return None if display_value.startswith("+"): return int(display_value[1:]) if display_value.startswith("-") and len(display_value) > 1: return int(display_value) - return 0 + return None except Exception: - return 0 + return None + + def _is_active_competitor(self, entry: Dict) -> bool: + """Return False for players who have missed the cut, withdrawn, or been DQ'd.""" + display_value = ((entry.get("score") or {}).get("displayValue") or "").upper().strip() + return display_value not in self._INACTIVE_SCORE_VALUES def _get_today_score(self, score_data: Dict) -> Optional[int]: """Get today's round score relative to par (None when not yet playing).""" diff --git a/plugins/masters-tournament/masters_helpers.py b/plugins/masters-tournament/masters_helpers.py index 95e5382..8c5ad2c 100644 --- a/plugins/masters-tournament/masters_helpers.py +++ b/plugins/masters-tournament/masters_helpers.py @@ -323,8 +323,13 @@ def format_player_name(name: str, max_length: int = 15) -> str: return name[:max_length - 2] + ".." -def format_score_to_par(score: int) -> str: - """Format score relative to par for display.""" +def format_score_to_par(score: Optional[int]) -> str: + """Format score relative to par for display. + + Returns "--" for None (inactive/unknown players such as MC, WD, DQ). + """ + if score is None: + return "--" if score == 0: return "E" elif score < 0: @@ -341,6 +346,31 @@ def calculate_scoring_average(rounds: List[Optional[int]]) -> Optional[float]: return sum(valid_rounds) / len(valid_rounds) +def _masters_thursday(year: int) -> datetime: + """Return the Masters Tournament start date (Thursday) for the given year. + + The Masters always starts on the Thursday that falls between April 6 and + April 12 inclusive — the first full Mon-Sun week of April that can contain + that Thursday. Verified against historical data: + + 2022 (Apr 1 = Fri) → Apr 7 + 2023 (Apr 1 = Sat) → Apr 6 + 2024 (Apr 1 = Mon) → Apr 11 + 2025 (Apr 1 = Tue) → Apr 10 + 2026 (Apr 1 = Wed) → Apr 9 + 2027 (Apr 1 = Thu) → Apr 8 + 2028 (Apr 1 = Sat) → Apr 6 + + Returns a timezone-aware datetime at 12:00 UTC (≈ 8 am ET tee-off). + """ + for day in range(6, 13): # April 6 .. April 12 inclusive + d = datetime(year, 4, day, 12, 0, 0, tzinfo=timezone.utc) + if d.weekday() == 3: # Thursday = 3 + return d + # Unreachable — there is always exactly one Thursday in any 7-day span. + raise RuntimeError(f"No Thursday found between April 6-12 for year {year}") + + def _to_eastern(date: Optional[datetime]) -> datetime: """Normalize a datetime to Eastern (Augusta) time.""" if date is None: @@ -373,12 +403,16 @@ def get_tournament_phase( return "practice" return "off-season" - if date.month == 4: - if 7 <= date.day <= 9: - return "practice" - elif 10 <= date.day <= 13: - return "tournament" - + # Fallback: compute the correct Thursday dynamically so the phase is + # accurate in future years without hardcoded day numbers. + thu = _masters_thursday(date.year) + thu_e = _to_eastern(thu) + start_e = thu_e + end_e = thu_e + timedelta(days=3, hours=23, minutes=59, seconds=59) + if start_e <= date <= end_e: + return "tournament" + if timedelta(0) <= (start_e - date) <= timedelta(days=3): + return "practice" return "off-season" @@ -429,39 +463,43 @@ def get_detailed_phase( return "pre-tournament" return "off-season" - month = date.month - day = date.day + # Fallback: compute the correct tournament window dynamically using the + # April 6-12 Thursday rule so this stays accurate in future years. + # Use calendar-day comparisons so that early-morning hours on tournament + # days (e.g. 6am Thursday) are not mistakenly classified as "practice". hour = date.hour + thu = _masters_thursday(date.year) + thu_e = _to_eastern(thu) + thu_date = thu_e.date() + sun_date = thu_date + timedelta(days=3) # Sunday = last day + mon_date = thu_date - timedelta(days=3) # Monday = first practice day + date_date = date.date() + + # Tournament days: Thursday through Sunday (calendar day) + if thu_date <= date_date <= sun_date: + if hour < 6: + return "tournament-overnight" + if hour < 8: + return "tournament-morning" + if hour < 19: + return "tournament-live" + return "tournament-evening" + + # Day after the tournament ends (Monday) + if date_date == sun_date + timedelta(days=1): + return "post-tournament" + + # Practice rounds: Mon-Wed of Masters week + if mon_date <= date_date < thu_date: + return "practice" + + # Pre-tournament: up to 2 weeks before (build anticipation). + # Compare date objects to avoid mixing aware/naive datetimes. + if timedelta(0) < (thu_date - date_date) <= timedelta(days=14): + return "pre-tournament" - # Masters is typically the second full week of April - # Practice: Mon-Wed, Tournament: Thu-Sun - # Adjust these dates each year as needed - if month == 4: - # Week before Masters (build anticipation) - if 1 <= day <= 6: - return "pre-tournament" - - # Practice rounds - if 7 <= day <= 9: - return "practice" - - # Tournament days (Thu=10 through Sun=13) - if 10 <= day <= 13: - if hour < 6: - return "tournament-overnight" - elif hour < 8: - return "tournament-morning" - elif hour < 19: - return "tournament-live" - else: - return "tournament-evening" - - # Monday after - if day == 14: - return "post-tournament" - - # March - countdown month - if month == 3 and day >= 20: + # Late March counts as pre-tournament too + if date.month == 3 and date.day >= 20: return "pre-tournament" return "off-season" @@ -600,7 +638,9 @@ def sort_key(player): pos = int(pos_str) except ValueError: pos = 999 - score = player.get("score", 999) + score = player.get("score") + if score is None: + score = 999 return (pos, score) return sorted(players, key=sort_key) diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index a79e254..7e0df7f 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -394,6 +394,8 @@ def _get_flag(self, country_code: str) -> Optional[Image.Image]: def _score_color(self, score, position=None) -> Tuple[int, int, int]: if position == 1: return COLORS["masters_yellow"] + if score is None: + return COLORS["light_gray"] if score < 0: return COLORS["under_par"] elif score > 0: @@ -479,7 +481,7 @@ def _draw_leaderboard_row( # Narrower columns need shorter names. name_budget = self.name_len if col_width >= self.width - 4 else max(6, self.name_len - 4) name = format_player_name(player.get("player", "?"), name_budget) - score = player.get("score", 0) + score = player.get("score") score_text = format_score_to_par(score) position = player.get("position", 99) is_leader = (isinstance(position, int) and position == 1) or pos_text == "1" @@ -634,7 +636,7 @@ def render_player_card(self, player: Dict, self._text_height(draw, country, self.font_detail)) + 2 # Score - big and prominent with spacing - score = player.get("score", 0) + score = player.get("score") score_text = format_score_to_par(score) if self.tier == "large": @@ -788,7 +790,7 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name, detail_font = _load_font_sized("4x6-font.ttf", detail_px) or self.font_detail # Reserve the right-hand score block width based on the actual score text. - score = player.get("score", 0) + score = player.get("score") score_text = format_score_to_par(score) score_w = self._text_width(draw, score_text, score_font) score_h = self._text_height(draw, score_text, score_font) @@ -886,55 +888,106 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name, def render_hole_card(self, hole_number: int, card_width: Optional[int] = None, card_height: Optional[int] = None) -> Optional[Image.Image]: + """Two-column hole card. + + Left column — hole information stacked top to bottom: + # (font_header — larger than the detail text) + Par + y + + Right column — hole map image with hole name centred below it. + + Tiny displays (≤32 wide) fall back to a single-column layout because + there is not enough horizontal space for two readable columns. + """ cw = card_width if card_width is not None else self.width ch = card_height if card_height is not None else self.height hole_info = get_hole_info(hole_number) - img = self._draw_gradient_bg((15, 80, 30), COLORS["augusta_green"], + img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"], width=cw, height=ch) draw = ImageDraw.Draw(img) - # Header - header_h = self.header_height - draw.rectangle([(0, 0), (cw - 1, header_h - 1)], fill=COLORS["masters_green"]) - draw.line([(0, header_h - 1), (cw, header_h - 1)], fill=COLORS["masters_yellow"]) - - hole_text = f"HOLE {hole_number}" - self._text_shadow(draw, (3, 1), hole_text, self.font_header, COLORS["white"]) - - if self.tier != "tiny": - name_text = hole_info["name"] - name_w = self._text_width(draw, name_text, self.font_detail) - draw.text((cw - name_w - 3, 2), name_text, + if self.tier == "tiny": + # Single-column fallback for 32-wide displays + draw.rectangle([(0, 0), (cw - 1, self.header_height - 1)], + fill=COLORS["masters_green"]) + draw.line([(0, self.header_height - 1), (cw, self.header_height - 1)], + fill=COLORS["masters_yellow"]) + self._text_shadow(draw, (2, 1), f"#{hole_number}", + self.font_header, COLORS["white"]) + y = self.header_height + 2 + draw.text((2, y), f"Par {hole_info['par']}", + fill=COLORS["white"], font=self.font_detail) + y += self._text_height(draw, "A", self.font_detail) + 1 + draw.text((2, y), f"{hole_info['yardage']}y", fill=COLORS["masters_yellow"], font=self.font_detail) + return img + + # ── Two-column layout (small / large tiers) ────────────────────── + # Left column width: wide enough for the hole number + par/yards text. + left_w = max(22, min(30, cw // 3)) + + # Vertical separator + draw.line([(left_w, 2), (left_w, ch - 2)], fill=COLORS["masters_yellow"]) + + line_h = self._text_height(draw, "A", self.font_detail) + 2 + + # ── Left column ───────────────────────────────────────────────── + # Hole number — use font_header so it renders larger than par/yards. + num_text = f"#{hole_number}" + num_h = self._text_height(draw, num_text, self.font_header) + num_w = self._text_width(draw, num_text, self.font_header) + self._text_shadow(draw, ((left_w - num_w) // 2, 3), + num_text, self.font_header, COLORS["white"]) + + # Par and yardage below the number + y_info = 3 + num_h + 3 + par_text = f"Par {hole_info['par']}" + yard_text = f"{hole_info['yardage']}y" + for label, color in ((par_text, COLORS["white"]), + (yard_text, COLORS["masters_yellow"])): + lw = self._text_width(draw, label, self.font_detail) + draw.text(((left_w - lw) // 2, y_info), label, + fill=color, font=self.font_detail) + y_info += line_h + + # Zone badge at the bottom of the left column (if room) + zone = hole_info.get("zone") + if zone: + badge = zone[:3].upper() + bw = self._text_width(draw, badge, self.font_detail) + by = ch - line_h - 1 + if by > y_info: + draw.text(((left_w - bw) // 2, by), badge, + fill=COLORS["azalea_pink"], font=self.font_detail) + + # ── Right column — hole map image + name below ─────────────────── + rx = left_w + 3 + right_w = cw - rx - 2 + + # Reserve bottom pixels for the hole name label + name_h = line_h + 1 + img_max_h = max(1, ch - name_h - 4) - # Hole layout image (clamp to min 1px for tiny displays) hole_img = self.logo_loader.get_hole_image( hole_number, - max_width=max(1, cw - 8), - max_height=max(1, ch - header_h - 14), + max_width=max(1, right_w), + max_height=img_max_h, ) if hole_img: - hx = (cw - hole_img.width) // 2 - hy = header_h + 2 + hx = rx + (right_w - hole_img.width) // 2 + hy = (img_max_h - hole_img.height) // 2 + 2 img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) - # Footer - footer_y = ch - 9 - draw.rectangle([(0, footer_y), (cw - 1, ch - 1)], fill=(0, 0, 0)) - info_text = f"Par {hole_info['par']} {hole_info['yardage']}y" - self._text_shadow(draw, (3, footer_y + 1), info_text, - self.font_detail, COLORS["white"]) - - zone = hole_info.get("zone") - if zone and self.tier != "tiny": - badge_text = zone.upper() - bw = self._text_width(draw, badge_text, self.font_detail) + 4 - draw.rectangle([(cw - bw - 2, footer_y), - (cw - 2, ch - 1)], - fill=COLORS["masters_dark"]) - draw.text((cw - bw, footer_y + 1), badge_text, - fill=COLORS["masters_yellow"], font=self.font_detail) + # Hole name centred at the bottom of the right column + name_text = hole_info["name"] + # Truncate to fit available width + while name_text and self._text_width(draw, name_text, self.font_detail) > right_w: + name_text = name_text[:-1] + nw = self._text_width(draw, name_text, self.font_detail) + draw.text((rx + (right_w - nw) // 2, ch - line_h - 1), + name_text, fill=COLORS["masters_yellow"], font=self.font_detail) return img @@ -1345,8 +1398,8 @@ def render_field_overview(self, leaderboard_data: List[Dict]) -> Optional[Image. self._draw_header_bar(img, draw, "THE FIELD") total = len(leaderboard_data) - under = sum(1 for p in leaderboard_data if p.get("score", 0) < 0) - over = sum(1 for p in leaderboard_data if p.get("score", 0) > 0) + under = sum(1 for p in leaderboard_data if (p.get("score") or 0) < 0) + over = sum(1 for p in leaderboard_data if (p.get("score") or 0) > 0) even = total - under - over line_h = 10 if self.tier == "large" else 8 diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 553196d..733a2a3 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -166,7 +166,7 @@ def render_player_card(self, player: Dict, y_text += 10 # Score - big - score = player.get("score", 0) + score = player.get("score") score_text = format_score_to_par(score) self._text_shadow(draw, (tx, y_text), score_text, self.font_score, self._score_color(score)) @@ -267,111 +267,83 @@ def render_hole_card(self, hole_number: int, def _render_hole_card_with_image(self, img, draw, hole_number: int, hole_info: Dict, cw: int, ch: int) -> Image.Image: - """Large-canvas layout: single text column on the left + hole image on the right. + """Large-canvas two-column layout. - Text column contents (stacked top to bottom): - [Hole #, Hole Name (wrapped), Par, Yardage] - Zone badge is drawn as a small chip in the bottom-right over the image. + Left column (info panel, stacked top-to-bottom): + # — font_header (larger than detail text) + Par — font_detail + y — font_detail + [zone] — font_detail, bottom of panel (if space) + + Right column: + Hole map image — centred vertically in available space + Hole name — centred at the very bottom of the column """ - # Left panel width: wide enough to fit "Golden Bell" and par/yardage text. - # Grows with card width so 256x64 cards get more text room than 128x48. - left_w = max(38, min(56, cw // 3)) + # Left panel width: narrower than before so the image gets more room. + left_w = max(32, min(46, cw // 4)) - # Left panel background strip + # Left panel background strip + separator draw.rectangle([(0, 0), (left_w - 1, ch - 1)], fill=COLORS["masters_dark"]) draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) - line_h = self._text_height(draw, "A", self.font_detail) + 1 + line_h = self._text_height(draw, "A", self.font_detail) + 2 max_text_w = left_w - 4 - # Top: hole number + # ── Hole number (top, larger font) ────────────────────────────── hole_text = f"#{hole_number}" hole_h = self._text_height(draw, hole_text, self.font_header) hw = self._text_width(draw, hole_text, self.font_header) - self._text_shadow(draw, ((left_w - hw) // 2, 2), hole_text, - self.font_header, COLORS["white"]) - top_bound = 2 + hole_h + 2 + self._text_shadow(draw, ((left_w - hw) // 2, 3), + hole_text, self.font_header, COLORS["white"]) - # Bottom: par + yardage pinned to actual canvas bottom + # ── Par + yardage directly below hole number ───────────────────── + y_info = 3 + hole_h + 3 par_text = f"Par {hole_info['par']}" yard_text = f"{hole_info['yardage']}y" - par_block_h = line_h * 2 - par_y = ch - par_block_h - 2 pw = self._text_width(draw, par_text, self.font_detail) - draw.text(((left_w - pw) // 2, par_y), par_text, - fill=COLORS["white"], font=self.font_detail) + draw.text(((left_w - pw) // 2, y_info), + par_text, fill=COLORS["white"], font=self.font_detail) + y_info += line_h yw = self._text_width(draw, yard_text, self.font_detail) - draw.text(((left_w - yw) // 2, par_y + line_h), yard_text, - fill=COLORS["light_gray"], font=self.font_detail) - bottom_bound = par_y - 2 + draw.text(((left_w - yw) // 2, y_info), + yard_text, fill=COLORS["masters_yellow"], font=self.font_detail) + y_info += line_h - # Middle: hole name — fit in whatever space is left between hole# and par - name_text = hole_info["name"] - name_slot = bottom_bound - top_bound - max_lines = max(1, name_slot // line_h) + # ── Zone badge at bottom of left panel (if room) ───────────────── + zone = hole_info.get("zone") + if zone and self.tier != "tiny": + badge = zone[:3].upper() # e.g. "AME", "FEA" + bw = self._text_width(draw, badge, self.font_detail) + by = ch - line_h - 1 + if by > y_info + 2: + draw.text(((left_w - bw) // 2, by), + badge, fill=COLORS["azalea_pink"], font=self.font_detail) + + # ── Right column — image + name ────────────────────────────────── + rx = left_w + 2 + right_w = cw - rx - 2 - name_lines: List[str] = [] - nw = self._text_width(draw, name_text, self.font_detail) - if nw <= max_text_w: - name_lines = [name_text] - else: - words = name_text.split() - current = "" - for word in words: - test = f"{current} {word}".strip() if current else word - if self._text_width(draw, test, self.font_detail) <= max_text_w: - current = test - else: - if current: - name_lines.append(current) - current = word - if current: - name_lines.append(current) - - # Clamp to available lines; ellipsize the last surviving line if clipped. - if len(name_lines) > max_lines: - name_lines = name_lines[:max_lines] - last = name_lines[-1] - while last and self._text_width(draw, last + "..", self.font_detail) > max_text_w: - last = last[:-1] - name_lines[-1] = (last + "..") if last else ".." - for idx, line in enumerate(name_lines): - while line and self._text_width(draw, line, self.font_detail) > max_text_w: - line = line[:-1] - name_lines[idx] = line - - block_h = len(name_lines) * line_h - name_y = top_bound + max(0, (name_slot - block_h) // 2) - for i, line in enumerate(name_lines): - lw = self._text_width(draw, line, self.font_detail) - draw.text(((left_w - lw) // 2, name_y + i * line_h), line, - fill=COLORS["masters_yellow"], font=self.font_detail) + # Reserve the bottom of the right column for the hole name + name_h = line_h + 2 + img_max_h = max(1, ch - name_h - 4) - # Right side: hole layout image - img_x = left_w + 2 - img_w = cw - img_x - 2 - img_h = ch - 4 hole_img = self.logo_loader.get_hole_image( hole_number, - max_width=img_w, - max_height=img_h, + max_width=max(1, right_w), + max_height=img_max_h, ) if hole_img: - hx = img_x + (img_w - hole_img.width) // 2 - hy = (ch - hole_img.height) // 2 + hx = rx + (right_w - hole_img.width) // 2 + hy = (img_max_h - hole_img.height) // 2 + 2 img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) - # Zone badge at bottom-right corner over the hole image - zone = hole_info.get("zone") - if zone and self.tier != "tiny": - badge = zone.upper() - bw = self._text_width(draw, badge, self.font_detail) + 4 - bx = cw - bw - 1 - by = ch - 9 - draw.rectangle([(bx, by), (cw - 1, ch - 1)], - fill=COLORS["masters_dark"]) - draw.text((bx + 2, by + 1), badge, - fill=COLORS["masters_yellow"], font=self.font_detail) + # Hole name centred at the very bottom of the right column + name_text = hole_info["name"] + while name_text and self._text_width(draw, name_text, self.font_detail) > right_w: + name_text = name_text[:-1] + nw = self._text_width(draw, name_text, self.font_detail) + draw.text((rx + (right_w - nw) // 2, ch - line_h - 1), + name_text, fill=COLORS["masters_yellow"], font=self.font_detail) return img diff --git a/plugins/masters-tournament/test_local.py b/plugins/masters-tournament/test_local.py new file mode 100644 index 0000000..83fb9ef --- /dev/null +++ b/plugins/masters-tournament/test_local.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +Local test script for masters-tournament plugin changes. + +Run from the plugin directory: + cd plugins/masters-tournament + python test_local.py + +Tests three things: + 1. _masters_thursday() date logic (no dependencies) + 2. get_detailed_phase() for various dates (no dependencies) + 3. Hole card visual rendering → saves PNG files you can open and inspect + (requires: pillow) + +Does NOT require a Pi, LED matrix, or network connection. +""" + +import os +import sys +from datetime import datetime, timezone, timedelta +from pathlib import Path + +# ── path setup ───────────────────────────────────────────────────────────────── +HERE = Path(__file__).parent +sys.path.insert(0, str(HERE)) + +# Stub out everything that would import rpi/ledmatrix core libs +from unittest.mock import MagicMock, patch + +# Stub src.plugin_system so helpers can be imported standalone +import types +src_mod = types.ModuleType("src") +ps_mod = types.ModuleType("src.plugin_system") +bp_mod = types.ModuleType("src.plugin_system.base_plugin") + +class _BasePlugin: + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + self.plugin_id = plugin_id + self.config = config + self.enabled = config.get("enabled", True) + self.logger = MagicMock() + def on_config_change(self, new_config): + self.config = new_config or {} + def cleanup(self): pass + def get_info(self): return {} + +class _VegasDisplayMode: + SCROLL = "scroll" + +bp_mod.BasePlugin = _BasePlugin +bp_mod.VegasDisplayMode = _VegasDisplayMode +ps_mod.base_plugin = bp_mod +src_mod.plugin_system = ps_mod +sys.modules["src"] = src_mod +sys.modules["src.plugin_system"] = ps_mod +sys.modules["src.plugin_system.base_plugin"] = bp_mod + +# ───────────────────────────────────────────────────────────────────────────── + +PASS = "\033[32mPASS\033[0m" +FAIL = "\033[31mFAIL\033[0m" +_failures = [] + +def check(label, got, expected): + ok = got == expected + status = PASS if ok else FAIL + print(f" {status} {label}") + if not ok: + print(f" got : {got!r}") + print(f" expected : {expected!r}") + _failures.append(label) + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. _masters_thursday date logic +# ═══════════════════════════════════════════════════════════════════════════════ +print("\n-- 1. _masters_thursday() ---------------------------------------------") +from masters_helpers import _masters_thursday + +KNOWN = { + 2022: (4, 7), + 2023: (4, 6), + 2024: (4, 11), + 2025: (4, 10), + 2026: (4, 9), + 2027: (4, 8), + 2028: (4, 6), +} +for year, (month, day) in KNOWN.items(): + thu = _masters_thursday(year) + check(f"{year} -> April {day}", (thu.month, thu.day), (month, day)) + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. get_detailed_phase() — fallback path (no ESPN dates supplied) +# ═══════════════════════════════════════════════════════════════════════════════ +print("\n-- 2. get_detailed_phase() fallback -----------------------------------") +from masters_helpers import get_detailed_phase + +_EDT = timezone(timedelta(hours=-4)) + +def edt(year, month, day, hour=12): + return datetime(year, month, day, hour, 0, 0, tzinfo=_EDT) + +# 2026 Masters: Thu Apr 9 – Sun Apr 12 +check("2026 Apr 8 (Wed practice)", get_detailed_phase(edt(2026, 4, 8)), "practice") +check("2026 Apr 9 10am (live)", get_detailed_phase(edt(2026, 4, 9, 10)), "tournament-live") +check("2026 Apr 9 6am (morning)", get_detailed_phase(edt(2026, 4, 9, 6)), "tournament-morning") +check("2026 Apr 9 20h (evening)", get_detailed_phase(edt(2026, 4, 9, 20)), "tournament-evening") +check("2026 Apr 12 10am (live)", get_detailed_phase(edt(2026, 4, 12, 10)), "tournament-live") +check("2026 Apr 13 noon (post)", get_detailed_phase(edt(2026, 4, 13, 12)), "post-tournament") +check("2026 Apr 1 (pre-tournament)", get_detailed_phase(edt(2026, 4, 1)), "pre-tournament") +check("2026 Jan 1 (off-season)", get_detailed_phase(edt(2026, 1, 1)), "off-season") + +# 2023 Masters: Thu Apr 6 – Sun Apr 9 (was broken with old hardcoded Apr 10-13) +check("2023 Apr 6 10am (live)", get_detailed_phase(edt(2023, 4, 6, 10)), "tournament-live") +check("2023 Apr 5 (practice)", get_detailed_phase(edt(2023, 4, 5)), "practice") +check("2023 Apr 4 (practice)", get_detailed_phase(edt(2023, 4, 4)), "practice") + +# 2024 Masters: Thu Apr 11 – Sun Apr 14 +check("2024 Apr 11 10am (live)", get_detailed_phase(edt(2024, 4, 11, 10)), "tournament-live") +check("2024 Apr 4 (pre-tournament)", get_detailed_phase(edt(2024, 4, 4)), "pre-tournament") + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. _build_enabled_modes() fun_facts config filtering +# ═══════════════════════════════════════════════════════════════════════════════ +print("\n-- 3. fun_facts config filtering --------------------------------------") + +# Minimal stubs for MastersTournamentPlugin init +import logging +logging.disable(logging.CRITICAL) # silence all logging during test + +# Stub heavy imports that plugin __init__ pulls in +for mod_name in ["masters_data", "masters_renderer", "masters_renderer_enhanced", + "logo_loader", "requests"]: + sys.modules.setdefault(mod_name, MagicMock()) + +from manager import MastersTournamentPlugin + +def _make_plugin(config): + dm = MagicMock() + dm.matrix.width = 64 + dm.matrix.height = 32 + cm = MagicMock() + cm.get = MagicMock(return_value=None) + cm.set = MagicMock() + pm = MagicMock() + p = MastersTournamentPlugin.__new__(MastersTournamentPlugin) + p.plugin_id = "masters-tournament" + p.config = config + p.enabled = True + p.logger = MagicMock() + p.display_width = 64 + p.display_height = 32 + p._tournament_meta = None + return p + +def modes_include_fun_facts(config): + p = _make_plugin(config) + # Force off-season phase so fun_facts is always a candidate mode; + # this test is about config-filtering logic, not phase selection. + import manager as _manager_mod + with patch.object(_manager_mod, "get_detailed_phase", return_value="off-season"): + modes = p._build_enabled_modes() + return "masters_fun_facts" in modes + +check("no display_modes config -> fun_facts enabled (default)", + modes_include_fun_facts({}), True) + +check("display_modes.fun_facts={'enabled': true} -> enabled", + modes_include_fun_facts({"display_modes": {"fun_facts": {"enabled": True}}}), True) + +check("display_modes.fun_facts={'enabled': false} -> disabled", + modes_include_fun_facts({"display_modes": {"fun_facts": {"enabled": False}}}), False) + +check("display_modes.fun_facts=True (bare bool) -> enabled", + modes_include_fun_facts({"display_modes": {"fun_facts": True}}), True) + +check("display_modes.fun_facts=False (bare bool) -> disabled", + modes_include_fun_facts({"display_modes": {"fun_facts": False}}), False) + +# ═══════════════════════════════════════════════════════════════════════════════ +# 4. Hole card visual rendering → PNG files +# ═══════════════════════════════════════════════════════════════════════════════ +print("\n-- 4. Hole card visual rendering --------------------------------------") + +try: + from PIL import Image + from masters_helpers import get_hole_info + + # Minimal logo_loader stub that returns no images (pure text layout) + class _FakeLoader: + def get_hole_image(self, *a, **kw): return None + def get_player_headshot(self, *a, **kw): return None + def get_green_jacket_icon(self, *a, **kw): return None + def clear_cache(self): pass + + # Remove the MagicMock stubs installed for section 3 so the real renderer + # modules are imported fresh here (section 3 only needed them to stub out + # manager's top-level imports, not for actual rendering). + for _m in ["masters_renderer", "masters_renderer_enhanced"]: + sys.modules.pop(_m, None) + + # Import real renderers + from masters_renderer import MastersRenderer + from masters_renderer_enhanced import MastersRendererEnhanced + + OUT_DIR = HERE / "test_renders" + OUT_DIR.mkdir(exist_ok=True) + + SIZES = [ + ("64x32_small", 64, 32, MastersRendererEnhanced), + ("128x64_large", 128, 64, MastersRendererEnhanced), + ("192x48_wide", 192, 48, MastersRendererEnhanced), + ] + TEST_HOLES = [1, 12, 13, 18] # regular + Amen Corner + final + + for size_name, w, h, RendererCls in SIZES: + loader = _FakeLoader() + r = RendererCls(w, h, {}, loader, MagicMock()) + for hole in TEST_HOLES: + img = r.render_hole_card(hole) + if img: + # Scale up 4× so it's easy to view on screen + scaled = img.resize((w * 4, h * 4), Image.NEAREST) + fname = OUT_DIR / f"hole_{hole:02d}_{size_name}.png" + scaled.save(fname) + print(f" {PASS} saved {fname.relative_to(HERE)}") + else: + print(f" {FAIL} render_hole_card({hole}) returned None on {size_name}") + _failures.append(f"render hole {hole} on {size_name}") + +except Exception as e: + print(f" {FAIL} rendering crashed: {e}") + import traceback; traceback.print_exc() + _failures.append("rendering") + +# ═══════════════════════════════════════════════════════════════════════════════ +# Summary +# ═══════════════════════════════════════════════════════════════════════════════ +print() +if _failures: + print(f"{'-'*60}") + print(f"FAILED ({len(_failures)}): {', '.join(_failures)}") + sys.exit(1) +else: + print(f"{'-'*60}") + print("All checks passed.") + print("\nOpen plugins/masters-tournament/test_renders/*.png to inspect the hole card layout.")