Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-04-09",
"last_updated": "2026-04-10",
"plugins": [
{
"id": "hello-world",
Expand Down Expand Up @@ -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",
Expand Down
62 changes: 50 additions & 12 deletions plugins/masters-tournament/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
24 changes: 22 additions & 2 deletions plugins/masters-tournament/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Comment on lines +51 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use ledmatrix_min in the new release entries.

The newly added versions still use ledmatrix_min_version, but the repo schema for manifest.json version entries is ledmatrix_min. Keeping the old key here can leave compatibility/store tooling reading incomplete metadata for the newest releases.

As per coding guidelines, "Add the new version FIRST (most recent at top) to the versions array in manifest.json, with fields: released (date), version (semver), and ledmatrix_min (minimum LEDMatrix version)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/masters-tournament/manifest.json` around lines 46 - 59, Update the
new release entries in manifest.json to use the schema key ledmatrix_min instead
of ledmatrix_min_version: in the versions array (newest entries at the top)
replace each "ledmatrix_min_version" property with "ledmatrix_min" and keep the
same values for the existing version, released and version fields so the
manifest conforms to the repo schema.

},
{
"version": "2.2.7",
"released": "2026-04-09",
Expand Down Expand Up @@ -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"
]
Expand Down
49 changes: 33 additions & 16 deletions plugins/masters-tournament/masters_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand All @@ -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 ─────────────────────────────────────

Expand Down Expand Up @@ -615,26 +611,47 @@ 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:
self.logger.error(f"Error parsing leaderboard: {e}")

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)."""
Expand Down
Loading