Skip to content
Merged
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
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@
"last_updated": "2026-04-09",
"verified": true,
"screenshot": "",
"latest_version": "2.1.2"
"latest_version": "2.2.4"
},
{
"id": "web-ui-info",
Expand Down
Binary file modified plugins/masters-tournament/assets/masters/flags/ARG.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/AUS.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/CAN.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/ENG.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/ESP.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/FIJ.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/GER.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/IRL.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/JPN.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/NIR.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/NOR.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/RSA.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/SCO.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/SWE.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/USA.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified plugins/masters-tournament/assets/masters/flags/WAL.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions plugins/masters-tournament/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@
"maximum": 3600,
"description": "How often to fetch new data in seconds (30s during tournament, 3600s off-season)"
},
"player_card_duration": {
"type": "integer",
"default": 8,
"minimum": 1,
"maximum": 300,
"description": "Seconds each player card is shown before rotating to the next player in the player card display mode"
},
"hole_display_duration": {
"type": "integer",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "Seconds between hole advances in course tour and hole-by-hole display modes"
},
"page_display_duration": {
"type": "integer",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "Seconds between page advances in paginated modes (leaderboard, champions, tournament stats, schedule, course overview)"
},
"mock_data": {
"type": "boolean",
"default": false,
Expand Down
15 changes: 12 additions & 3 deletions plugins/masters-tournament/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,10 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man
self._last_page_advance = {} # per-mode page timers
self._page_interval = config.get("page_display_duration", 15)

# Player card rotation
# Player card rotation — dwell on each card for N seconds.
self._player_card_index = 0
self._last_player_card_advance = 0.0
self._player_card_interval = config.get("player_card_duration", 8)

self.logger.info(
f"Masters Tournament plugin initialized: {self.display_width}x{self.display_height}, "
Expand Down Expand Up @@ -423,10 +425,16 @@ def _display_leaderboard(self, force_clear: bool) -> bool:
def _display_player_cards(self, force_clear: bool) -> bool:
if not self._leaderboard_data:
return False
# Rotate through top players
# Rotate through top players on a dwell timer (not every frame) so
# viewers actually get to read each card.
now = time.time()
if self._last_player_card_advance == 0.0:
self._last_player_card_advance = now
elif now - self._last_player_card_advance >= self._player_card_interval:
self._player_card_index += 1
self._last_player_card_advance = now
idx = self._player_card_index % min(5, len(self._leaderboard_data))
player = self._leaderboard_data[idx]
self._player_card_index += 1
return self._show_image(self.renderer.render_player_card(player))

def _display_course_tour(self, force_clear: bool) -> bool:
Expand Down Expand Up @@ -579,6 +587,7 @@ def on_config_change(self, new_config):
self.display_duration = new_config.get("display_duration", 20)
self._hole_switch_interval = new_config.get("hole_display_duration", 15)
self._page_interval = new_config.get("page_display_duration", 15)
self._player_card_interval = new_config.get("player_card_duration", 8)
self._last_hole_advance.clear()
self._last_page_advance.clear()
self.modes = self._build_enabled_modes()
Expand Down
27 changes: 26 additions & 1 deletion 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.1.2",
"version": "2.2.4",
"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,31 @@
"height": 64
},
"versions": [
{
"version": "2.2.4",
"released": "2026-04-09",
"ledmatrix_min_version": "2.0.0"
},
{
"version": "2.2.3",
"released": "2026-04-09",
"ledmatrix_min_version": "2.0.0"
},
{
"version": "2.2.2",
"released": "2026-04-09",
"ledmatrix_min_version": "2.0.0"
},
{
"version": "2.2.1",
"released": "2026-04-09",
"ledmatrix_min_version": "2.0.0"
},
{
"version": "2.2.0",
"released": "2026-04-09",
"ledmatrix_min_version": "2.0.0"
},
{
"version": "2.1.2",
"released": "2026-04-09",
Expand Down
62 changes: 45 additions & 17 deletions plugins/masters-tournament/masters_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
CACHE_KEY_META = "masters_tournament_meta"
CACHE_KEY_SCHEDULE = "masters_schedule"

# Sentinel passed to cache_manager.get(max_age=...) when we want "return
# whatever exists, even if stale". The LEDMatrix core CacheManager.get()
# signature is `max_age: int = 300` — it doesn't accept None, so we use a
# very large finite value (~68 years) to effectively disable expiry at the
# read site.
_NEVER_EXPIRE = 2**31 - 1


class MastersDataSource:
"""Fetches and caches Masters Tournament data from ESPN Golf API."""
Expand Down Expand Up @@ -119,7 +126,7 @@ def fetch_tournament_meta(self) -> Optional[Dict]:
"""
cached = self.cache_manager.get(CACHE_KEY_META, max_age=self._get_cache_ttl())
if cached:
return cached
return self._rehydrate_meta(cached)

# Meta lives alongside the leaderboard payload; a leaderboard fetch
# will populate it as a side effect.
Expand All @@ -128,14 +135,32 @@ def fetch_tournament_meta(self) -> Optional[Dict]:
except Exception as e:
self.logger.warning(f"fetch_tournament_meta: leaderboard fetch failed: {e}")

cached = self.cache_manager.get(CACHE_KEY_META, max_age=None)
cached = self.cache_manager.get(CACHE_KEY_META, max_age=_NEVER_EXPIRE)
if cached:
return cached
return self._rehydrate_meta(cached)

# Final fallback: compute the Masters as the second Thursday of April
# so off-season countdowns still work.
return self._computed_fallback_meta()

@classmethod
def _rehydrate_meta(cls, cached: Dict) -> Dict:
"""Convert cached meta date fields back to tz-aware datetimes.

The core CacheManager serializes to JSON on disk, which turns our
datetime objects into ISO strings. Consumers (countdown, phase
detection, TTL computation) all expect datetime instances, so we
rehydrate here at the single read boundary.
"""
if not isinstance(cached, dict):
return cached
meta = dict(cached)
for key in ("start_date", "end_date"):
value = meta.get(key)
if isinstance(value, str):
meta[key] = cls._parse_iso_utc(value)
return meta

def _parse_tournament_meta(self, data: Dict) -> Optional[Dict]:
"""Extract tournament meta from an ESPN leaderboard response."""
try:
Expand Down Expand Up @@ -277,7 +302,7 @@ def fetch_schedule(self) -> List[Dict]:
self.logger.error(f"fetch_schedule: leaderboard refresh failed: {e}")
return self._get_fallback_data(cache_key)

cached = self.cache_manager.get(cache_key, max_age=None)
cached = self.cache_manager.get(cache_key, max_age=_NEVER_EXPIRE)
if cached is not None:
return cached
return []
Expand Down Expand Up @@ -624,24 +649,27 @@ def _get_cache_ttl(self) -> int:
Avoids calling fetch_tournament_meta() (which could recurse into
fetch_leaderboard) — only reads whatever is already in cache.
"""
meta = self.cache_manager.get(CACHE_KEY_META, max_age=None)
if meta:
status = meta.get("status")
if status == "in":
return 30
start = meta.get("start_date")
end = meta.get("end_date")
now = datetime.now(timezone.utc)
if start and end and start <= now <= end:
return 30
if start and timedelta(0) <= start - now <= timedelta(days=3):
return 300
raw = self.cache_manager.get(CACHE_KEY_META, max_age=_NEVER_EXPIRE)
if not raw:
return 3600
meta = self._rehydrate_meta(raw)
status = meta.get("status")
if status == "in":
return 30
start = meta.get("start_date")
end = meta.get("end_date")
if not isinstance(start, datetime):
return 3600
now = datetime.now(timezone.utc)
if isinstance(end, datetime) and start <= now <= end:
return 30
if timedelta(0) <= start - now <= timedelta(days=3):
return 300
return 3600

def _get_fallback_data(self, cache_key: str) -> List[Dict]:
"""Get stale cached data or mock data as fallback."""
cached = self.cache_manager.get(cache_key, max_age=None)
cached = self.cache_manager.get(cache_key, max_age=_NEVER_EXPIRE)
if cached:
self.logger.warning("Using stale cached data for %s", cache_key)
return cached
Expand Down
55 changes: 54 additions & 1 deletion plugins/masters-tournament/masters_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,61 @@
# HELPER FUNCTIONS
# ═══════════════════════════════════════════════════════════════

import unicodedata

# Characters that don't decompose via NFKD (single-codepoint letters with no
# base+combining form). Extend here if new player nationalities show up.
_ASCII_FALLBACK = {
"ø": "o", "Ø": "O",
"æ": "ae", "Æ": "AE",
"œ": "oe", "Œ": "OE",
"ß": "ss",
"ð": "d", "Ð": "D",
"þ": "th", "Þ": "Th",
"ł": "l", "Ł": "L",
"đ": "d", "Đ": "D",
"ħ": "h", "Ħ": "H",
"ı": "i", "İ": "I",
"ŋ": "n", "Ŋ": "N",
"\u2013": "-", "\u2014": "-", # en-dash, em-dash
"\u2018": "'", "\u2019": "'", # smart single quotes
"\u201C": '"', "\u201D": '"', # smart double quotes
}


def ascii_safe(text: str) -> str:
"""Transliterate a string to plain ASCII for our bitmap fonts.

Our rendering fonts (PressStart2P and especially 4x6-font) don't ship
with Latin Extended glyphs, so player names like "Højgaard", "Åberg",
"José María", "Välimäki" either render missing-glyph boxes or drop
characters entirely. Normalize NFKD to split combining accents, strip
the combiners, then apply an explicit map for single-codepoint letters
that don't decompose (ø, æ, ß, ł, ...). Everything else is passed
through if it's already ASCII, and non-ASCII leftovers are dropped.
"""
if not text or text.isascii():
return text
# Explicit multi-codepoint-safe replacements first (ø -> o, æ -> ae, etc).
# str.maketrans requires single-char keys, but our map has "ae"/"AE"
# values that are multi-char, so iterate explicitly.
out_chars: List[str] = []
for ch in text:
if ch in _ASCII_FALLBACK:
out_chars.append(_ASCII_FALLBACK[ch])
else:
out_chars.append(ch)
text = "".join(out_chars)
# Decompose combining accents (é -> e + ́) then strip the combiners.
normalized = unicodedata.normalize("NFKD", text)
result = "".join(ch for ch in normalized if not unicodedata.combining(ch))
# Drop any remaining non-ASCII.
return result.encode("ascii", "ignore").decode("ascii")


def format_player_name(name: str, max_length: int = 15) -> str:
"""Format player name to fit within character limit."""
"""Format player name to fit within character limit (ASCII-safe)."""
name = ascii_safe(name)
if len(name) <= max_length:
return name

Expand Down
Loading