diff --git a/plugins.json b/plugins.json index 41ef668a..54df31ef 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-28", + "last_updated": "2026-07-29", "plugins": [ { "id": "cricket-scoreboard", @@ -76,7 +76,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.19.3" + "latest_version": "1.20.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.7.1" + "latest_version": "1.8.0" }, { "id": "calendar", @@ -215,7 +215,7 @@ "last_updated": "2026-07-18", "verified": true, "screenshot": "", - "latest_version": "1.5.1" + "latest_version": "1.6.0" }, { "id": "football-scoreboard", @@ -240,7 +240,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "2.8.3" + "latest_version": "2.9.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.4.1", + "latest_version": "1.5.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.4.1", + "latest_version": "1.5.0", "icon": "fas fa-baseball-ball" }, { @@ -735,7 +735,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "2.3.1" + "latest_version": "2.4.0" }, { "id": "static-image", @@ -880,7 +880,7 @@ "last_updated": "2026-05-15", "verified": true, "screenshot": "", - "latest_version": "1.2.4", + "latest_version": "1.3.0", "icon": "fas fa-fist-raised" }, { @@ -1023,7 +1023,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.0.2", + "latest_version": "1.1.0", "last_updated": "2026-07-17" }, { @@ -1070,7 +1070,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.0.4" + "latest_version": "1.1.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/README.md b/plugins/afl-scoreboard/README.md index 305d6538..5f398399 100644 --- a/plugins/afl-scoreboard/README.md +++ b/plugins/afl-scoreboard/README.md @@ -92,6 +92,13 @@ config (there is no per-league nesting). | `dynamic_duration` | off | Size a mode's total on-screen time to the number of games it has. | | `mode_durations` | null | Fix the total duration of each mode (overrides dynamic calculation). | +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## Team Names & Abbreviations The `favorite_teams` / `exclude_teams` fields require the **ESPN API @@ -144,6 +151,9 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` ## Troubleshooting +- **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): + the plugin couldn't read your global timezone. Set `timezone` under the + plugin's Advanced Settings to your IANA zone, e.g. `America/Chicago`. - **No games showing**: Confirm the ESPN endpoint is reachable and that at least one display mode is enabled. - **Missing team logos**: The plugin auto-downloads logos; check the display's diff --git a/plugins/afl-scoreboard/afl_timezone.py b/plugins/afl-scoreboard/afl_timezone.py new file mode 100644 index 00000000..ca79fbfc --- /dev/null +++ b/plugins/afl-scoreboard/afl_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the AFL scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the AFL scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 50e2c7ac..02e0ccc8 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -272,6 +272,12 @@ "description": "Also celebrate when the opponent scores, not just favorites (off by default)", "x-advanced": true }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "game_limits": { "type": "object", "title": "Game Limits", @@ -938,6 +944,7 @@ "celebration_enabled", "celebration_duration", "celebrate_opponent_goals", + "timezone", "game_limits", "display_options", "filtering", diff --git a/plugins/afl-scoreboard/manager.py b/plugins/afl-scoreboard/manager.py index a7249521..c666ebae 100644 --- a/plugins/afl-scoreboard/manager.py +++ b/plugins/afl-scoreboard/manager.py @@ -40,6 +40,8 @@ # Import the manager factory from afl_managers import create_afl_managers +from afl_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) # The single AFL "league" key and its display name. @@ -265,12 +267,16 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]: } } - # Global config - timezone - timezone_str = cfg.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=cfg, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) display_config = cfg.get("display", {}) if not display_config and hasattr(self.cache_manager, 'config_manager'): diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index ac8c9a52..eeb2554f 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.0.2", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "released": "2026-07-29", + "version": "1.1.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.0.2", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 775947be..60d35263 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -19,6 +19,7 @@ from dynamic_team_resolver import DynamicTeamResolver from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from afl_timezone import resolve_timezone # Import main logo downloader (same as football plugin) import sys @@ -769,22 +770,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/afl-scoreboard/test_timezone_resolution.py b/plugins/afl-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..8b5239b2 --- /dev/null +++ b/plugins/afl-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for afl_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/afl-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import afl_timezone # noqa: E402 +from afl_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = afl_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + afl_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + afl_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + afl_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + afl_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + afl_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + afl_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 99286bb8..3c58c1c4 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.20.0] - 2026-07-28 + +### Fixed +- **Game start times shown in UTC**: The plugin read the LEDMatrix global timezone only from `cache_manager.config_manager`. On cores that hang `config_manager` off the plugin manager instead, that lookup came back empty and every start time was rendered in UTC — a 6:45pm Central first pitch displayed as `11:45PM` — while plugins that check the plugin manager first (clock-simple, geochron) showed the correct local time on the same device. Timezone resolution now lives in one place (`baseball_timezone.py`) shared by the switch-mode scorebug, the scroll-mode game card and the plugin manager, and tries, in order: the plugin's own `timezone` setting, `plugin_manager.config_manager`, `cache_manager.config_manager`, the host system zone (`TZ`, `/etc/timezone`, `/etc/localtime`), and only then UTC. +- **`timezone` setting was silently discarded**: The key was never declared in `config_schema.json`, which sets `additionalProperties: false`, so hand-editing it in the saved config had no effect. It is now a documented string property under Advanced Settings. +- **Plugin no longer writes a timezone back into your config**: The manager used to assign `self.config["timezone"]`, mutating the dict the core handed it and persisting a bogus `"timezone": "UTC"` into the saved plugin config. The resolved value is now kept on the instance and passed to sub-components via a copy. + +### Added +- `timezone` (Advanced Settings): optional IANA zone override, e.g. `America/Chicago`. Blank (the default) follows the LEDMatrix global timezone. + ## [1.19.0] - 2026-07-09 ### Added diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 606ee3c5..e03a0f37 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -32,6 +32,10 @@ A plugin for LEDMatrix that displays live, recent, and upcoming baseball games a - `show_records`: Display team win-loss records (default: false) - `show_ranking`: Display team rankings when available (default: false) - `background_service`: Configure API request settings +- `timezone` (Advanced): IANA name used to display game start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. ### Per-League Settings @@ -401,6 +405,12 @@ service. ## Troubleshooting +- **Game times look like UTC** (a 6:45pm Central first pitch showing as + 11:45PM): the plugin couldn't read your global timezone. Set `timezone` + under the plugin's Advanced Settings to your IANA zone, e.g. + `America/Chicago`. If your config already has a `timezone` entry stuck on + `"UTC"` from a version before 1.20.0, clear it or set it to your zone — + an explicit value there overrides everything else. - **No games showing**: Check if leagues are enabled and API endpoints are accessible - **Missing team logos**: Ensure team logo files exist in your assets/sports/ directory - **Slow updates**: Adjust the update interval in league configuration diff --git a/plugins/baseball-scoreboard/baseball_timezone.py b/plugins/baseball-scoreboard/baseball_timezone.py new file mode 100644 index 00000000..1777925c --- /dev/null +++ b/plugins/baseball-scoreboard/baseball_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the baseball scoreboard plugin. + +Game start times arrive from ESPN/MLB in UTC and have to be converted to the +user's local zone before they are drawn. This module owns the "which zone?" +decision so the switch-mode scorebug (``sports.py``), the scroll-mode game card +(``game_renderer.py``) and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + ``SportsCore._get_timezone()`` runs this once per game, and the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the baseball scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 1b6605b3..6ab6b705 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -31,6 +31,12 @@ "maximum": 60, "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display game start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "mlb": { "type": "object", "title": "MLB Settings", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 0e30022e..402441f0 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -11,9 +11,10 @@ import os from typing import Any, Dict, Optional -import pytz from PIL import Image, ImageDraw, ImageFont +from baseball_timezone import resolve_timezone + # Maps the core FontManager's common-font family aliases (src/font_manager.py # `common_fonts`) to the real files in assets/fonts. A font config value may be # either one of these family names or a literal filename; aliases resolve here, @@ -484,12 +485,7 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: if start_time: try: dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) - tz_name = self.config.get('timezone') or 'UTC' - try: - local_tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: - self.logger.warning("Unknown timezone %r; falling back to UTC", tz_name) - local_tz = pytz.UTC + local_tz = resolve_timezone(config=self.config, log=self.logger) dt_local = dt.astimezone(local_tz) game_date = dt_local.strftime('%b %d') game_time = dt_local.strftime('%-I:%M %p') diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 7af5cbad..daa7fd2d 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -50,6 +50,7 @@ NCAABaseballUpcomingManager, ) from milb_managers import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager +from baseball_timezone import resolve_timezone_name # Import scroll display components try: @@ -92,22 +93,17 @@ def __init__( self.logger = logger - # Resolve timezone: plugin config → global config → UTC. - # Inject into self.config so all sub-components (scroll display, game - # renderer, etc.) can read it via config.get('timezone'). - if not self.config.get("timezone"): - global_tz = None - config_manager = getattr(cache_manager, "config_manager", None) - if config_manager is not None: - try: - global_tz = config_manager.get_timezone() - except (AttributeError, TypeError): - self.logger.debug("Global timezone unavailable; falling back to UTC") - except Exception: - self.logger.exception( - "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." - ) - self.config["timezone"] = global_tz or "UTC" + # Resolve timezone: plugin override → global config (either manager) → + # system zone → UTC. Kept on the instance rather than written back into + # self.config: mutating the dict the core handed us used to persist a + # bogus "timezone": "UTC" into the user's saved plugin config. + self.timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=self.logger, + ) + self.logger.info(f"Baseball scoreboard using timezone: {self.timezone_str}") # Basic configuration self.is_enabled = config.get("enabled", True) @@ -174,7 +170,7 @@ def __init__( try: self._scroll_manager = ScrollDisplayManager( self.display_manager, - self.config, + self._sub_component_config(), self.logger ) self.logger.info("Scroll display manager initialized") @@ -261,19 +257,12 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.config = new_config or {} # Resolve timezone the same way __init__ does so sub-managers inherit it. - if not self.config.get("timezone"): - global_tz = None - config_manager = getattr(self.cache_manager, "config_manager", None) - if config_manager is not None: - try: - global_tz = config_manager.get_timezone() - except (AttributeError, TypeError): - self.logger.debug("Global timezone unavailable; falling back to UTC") - except Exception: - self.logger.exception( - "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." - ) - self.config["timezone"] = global_tz or "UTC" + self.timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=self.plugin_manager, + cache_manager=self.cache_manager, + log=self.logger, + ) # Re-derive scalar settings. self.enabled = self.config.get("enabled", getattr(self, "enabled", True)) @@ -301,7 +290,7 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: if SCROLL_AVAILABLE and ScrollDisplayManager: try: self._scroll_manager = ScrollDisplayManager( - self.display_manager, self.config, self.logger + self.display_manager, self._sub_component_config(), self.logger ) except Exception as e: self.logger.warning(f"Could not rebuild scroll display manager: {e}") @@ -724,13 +713,9 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" - + # Timezone was resolved once at init/config-change time. + timezone_str = self.timezone_str + # Get display config from main config if available display_config = self.config.get("display", {}) if not display_config and hasattr(self.cache_manager, 'config_manager'): @@ -750,7 +735,17 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") return manager_config - + + def _sub_component_config(self) -> Dict[str, Any]: + """Plugin config with the resolved timezone folded in. + + Sub-components that receive the whole config (the scroll display manager + and, through it, the game card renderer) read ``config['timezone']``. + Hand them a copy rather than mutating the core's dict, which would leak + the resolved value back into the user's saved config. + """ + return {**self.config, "timezone": self.timezone_str} + def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: """ Parse display mode settings from config. diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c69c6902..8ff42bb8 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.19.3", + "version": "1.20.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-07-28", + "version": "1.20.0", + "notes": "Fix game start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC (a 6:45pm Central first pitch showed as 11:45PM) even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was rejected by the config schema, so hand-editing it had no effect -- and stops the plugin writing a resolved timezone back into the saved config.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-18", "version": "1.19.3", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 3f959ee2..ea093630 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -38,6 +38,7 @@ def resolve_font_name(font_name: str) -> str: from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from baseball_timezone import resolve_timezone class SportsCore(ABC): @@ -704,22 +705,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone game start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/baseball-scoreboard/test_timezone_resolution.py b/plugins/baseball-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..b8a30666 --- /dev/null +++ b/plugins/baseball-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for baseball_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/baseball-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import baseball_timezone # noqa: E402 +from baseball_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = baseball_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """SportsCore._get_timezone() runs per game; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + baseball_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + baseball_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + baseball_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + baseball_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + baseball_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + baseball_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT first pitch -- the exact symptom that + # started this: a Chicago game rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/README.md b/plugins/basketball-scoreboard/README.md index e99281c3..ac691a58 100644 --- a/plugins/basketball-scoreboard/README.md +++ b/plugins/basketball-scoreboard/README.md @@ -36,6 +36,10 @@ A plugin for LEDMatrix that displays live, recent, and upcoming basketball games - `update_interval`: How often to fetch new data in seconds (30-86400, default: 3600) - `game_display_duration`: Duration to show each individual game before rotating to next game (3-60 seconds, default: 15) - `background_service`: Configure API request settings (timeout, retries, priority) +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. ### Per-League Settings @@ -383,6 +387,9 @@ shorten). Leave it at `0` to display every live game for `live_game_duration`. ## Troubleshooting +- **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): + the plugin couldn't read your global timezone. Set `timezone` under the + plugin's Advanced Settings to your IANA zone, e.g. `America/Chicago`. - **No games showing**: - Check if leagues are enabled in configuration - Verify API endpoints are accessible diff --git a/plugins/basketball-scoreboard/basketball_timezone.py b/plugins/basketball-scoreboard/basketball_timezone.py new file mode 100644 index 00000000..fa7dfd5a --- /dev/null +++ b/plugins/basketball-scoreboard/basketball_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the basketball scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the basketball scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index a811ad19..3abc13a2 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -32,6 +32,12 @@ "maximum": 60, "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "background_service": { "type": "object", "title": "Background Service Settings", @@ -1984,6 +1990,7 @@ "display_duration", "update_interval", "game_display_duration", + "timezone", "nba", "wnba", "ncaam", diff --git a/plugins/basketball-scoreboard/manager.py b/plugins/basketball-scoreboard/manager.py index e72ea04d..e83802de 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -43,6 +43,8 @@ NCAAWBasketballUpcomingManager, ) +from basketball_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) @@ -792,12 +794,16 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) # Get display config from main config if available display_config = self.config.get("display", {}) diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index f5c6a121..4ecef0b6 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.7.1", + "version": "1.8.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-07-29", + "version": "1.8.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.7.1", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 966b7b95..0dde0416 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -19,6 +19,7 @@ from dynamic_team_resolver import DynamicTeamResolver from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from basketball_timezone import resolve_timezone # Import main logo downloader (same as football plugin) import sys @@ -671,22 +672,17 @@ def attach_odds_when_ready(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/basketball-scoreboard/test_timezone_resolution.py b/plugins/basketball-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..856a2aaa --- /dev/null +++ b/plugins/basketball-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for basketball_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/basketball-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import basketball_timezone # noqa: E402 +from basketball_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = basketball_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + basketball_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + basketball_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + basketball_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + basketball_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + basketball_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + basketball_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/f1-scoreboard/README.md b/plugins/f1-scoreboard/README.md index b0dcbc5c..bc7dd4d0 100644 --- a/plugins/f1-scoreboard/README.md +++ b/plugins/f1-scoreboard/README.md @@ -177,6 +177,13 @@ Set `favorite_team` to one of these constructor IDs: | `sauber` | Sauber (Audi) | | `cadillac` | Cadillac | +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## Data sources | Data | Source | diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index b63d26d3..bfac5ac1 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -39,6 +39,12 @@ "default": "", "description": "Favorite driver code (e.g., VER, NOR, PIA, LEC, SAI, HAM, RUS, ALO, STR). This driver will always be shown and highlighted in standings and results." }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "driver_standings": { "type": "object", "title": "Driver Standings", @@ -733,7 +739,7 @@ "additionalProperties": false } }, - "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "dynamic_duration", "scroll", "visual", "customization"], + "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "timezone", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "dynamic_duration", "scroll", "visual", "customization"], "additionalProperties": false, "required": ["enabled"] } diff --git a/plugins/f1-scoreboard/f1_timezone.py b/plugins/f1-scoreboard/f1_timezone.py new file mode 100644 index 00000000..d5bb8f66 --- /dev/null +++ b/plugins/f1-scoreboard/f1_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the F1 scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the F1 scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index a869788a..e38af02f 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -21,6 +21,8 @@ from scroll_display import ScrollDisplayManager from team_colors import normalize_constructor_id +from f1_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) @@ -64,7 +66,7 @@ def __init__(self, plugin_id, config, display_manager, # effect immediately. Kept in a shallow copy (never written back into # `config`) so the resolved value never gets persisted as a stale # plugin-level override that would shadow future global changes. - self.timezone = self._resolve_timezone(config, cache_manager) + self.timezone = self._resolve_timezone(config, cache_manager, plugin_manager) render_config = {**config, "timezone": self.timezone} # Initialize components @@ -124,22 +126,20 @@ def __init__(self, plugin_id, config, display_manager, self.logger.info("F1 Scoreboard initialized with %d modes: %s", len(self.modes), ", ".join(self.modes)) - def _resolve_timezone(self, config: Dict, cache_manager) -> str: - """Resolve timezone: plugin config → global config → UTC.""" - tz = config.get("timezone") - if tz: - return tz - config_manager = getattr(cache_manager, "config_manager", None) - if config_manager is not None: - try: - tz = config_manager.get_timezone() - except (AttributeError, TypeError): - self.logger.debug("Global timezone unavailable; falling back to UTC") - except Exception: - self.logger.exception( - "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." - ) - return tz or "UTC" + def _resolve_timezone(self, config: Dict, cache_manager, plugin_manager=None) -> str: + """Resolve timezone: plugin config → global config → system zone → UTC. + + Consulting only ``cache_manager.config_manager`` used to fall through to + UTC on cores that expose ``config_manager`` via the plugin manager + instead, rendering every session start time in UTC. + """ + return resolve_timezone_name( + config=config, + plugin_manager=plugin_manager if plugin_manager is not None + else getattr(self, "plugin_manager", None), + cache_manager=cache_manager, + log=self.logger, + ) def _build_enabled_modes(self) -> List[str]: """Build list of enabled display modes from config.""" diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 00decb59..a64f907d 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.5.1", + "version": "1.6.0", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -29,6 +29,12 @@ "f1_calendar" ], "versions": [ + { + "released": "2026-07-29", + "version": "1.6.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-18", "version": "1.5.1", diff --git a/plugins/f1-scoreboard/test_timezone_resolution.py b/plugins/f1-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..62e83e1a --- /dev/null +++ b/plugins/f1-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for f1_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/f1-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import f1_timezone # noqa: E402 +from f1_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = f1_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + f1_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + f1_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + f1_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + f1_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + f1_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + f1_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index a6dd5198..5bedc5b8 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [2.9.0] - 2026-07-29 + +### Fixed +- **Game start times shown in UTC**: The plugin read the LEDMatrix global timezone only from `cache_manager.config_manager`. On cores that hang `config_manager` off the plugin manager instead, that lookup came back empty and every start time was rendered in UTC, while plugins that check the plugin manager first (clock-simple, geochron) showed the correct local time on the same device. Timezone resolution now lives in `football_timezone.py`, shared by the scorebug and the plugin manager, and tries, in order: the plugin's own `timezone` setting, `plugin_manager.config_manager`, `cache_manager.config_manager`, the host system zone (`TZ`, `/etc/timezone`, `/etc/localtime`), and only then UTC. +- **`timezone` setting was silently discarded**: The key was never declared in `config_schema.json`, which sets `additionalProperties: false`, so hand-editing it in the saved config had no effect. It is now a documented string property under Advanced Settings. +- **Plugin no longer writes a timezone back into your config**: `on_config_change` used to assign `self.config["timezone"]`, mutating the dict the core handed it and persisting a stale `"timezone": "UTC"` into the saved plugin config, which then shadowed the real global timezone. + +### Added +- `timezone` (Advanced Settings): optional IANA zone override, e.g. `America/Chicago`. Blank (the default) follows the LEDMatrix global timezone. + ## [2.8.1] - 2026-07-10 ### Fixed diff --git a/plugins/football-scoreboard/README.md b/plugins/football-scoreboard/README.md index 83f82f9c..35576146 100644 --- a/plugins/football-scoreboard/README.md +++ b/plugins/football-scoreboard/README.md @@ -486,9 +486,19 @@ With both `show_favorite_teams_only` and `show_all_live` off, all live games rot - Records/rankings - Betting odds +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## 🐛 Troubleshooting ### Common Issues +- **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): + the plugin couldn't read your global timezone. Set `timezone` under the + plugin's Advanced Settings to your IANA zone, e.g. `America/Chicago`. - **No games showing**: Check if leagues are enabled and favorite teams are configured - **Missing team logos**: Logos are automatically downloaded from ESPN API - **Slow updates**: Adjust the `live_update_interval` in league configuration diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index c14ba63d..93850c3d 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -775,6 +775,12 @@ "default": "classic", "description": "Layout engine. 'classic' is the original fixed layout (unchanged). 'adaptive' (beta) scales fonts, logos, and element regions to the panel size — content grows on large panels and degrades gracefully on small ones. Your customization fonts and x/y offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "customization": { "type": "object", "title": "Display Customization", diff --git a/plugins/football-scoreboard/football_timezone.py b/plugins/football-scoreboard/football_timezone.py new file mode 100644 index 00000000..a67f0370 --- /dev/null +++ b/plugins/football-scoreboard/football_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the football scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the football scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 3bdd575d..4f56c52f 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -58,6 +58,8 @@ ScrollDisplayManager = None SCROLL_AVAILABLE = False +from football_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) @@ -239,21 +241,11 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: """ self.config = new_config or {} - # Resolve timezone the same way the managers expect (cache_manager owns - # the global timezone); _adapt_config_for_manager re-reads it per league. - if not self.config.get("timezone"): - global_tz = None - config_manager = getattr(self.cache_manager, "config_manager", None) - if config_manager is not None: - try: - global_tz = config_manager.get_timezone() - except (AttributeError, TypeError): - self.logger.debug("Global timezone unavailable; falling back to UTC") - except Exception: - self.logger.exception( - "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." - ) - self.config["timezone"] = global_tz or "UTC" + # Resolve timezone for the managers. Deliberately NOT written back into + # self.config: mutating the dict the core handed us used to persist a + # bogus "timezone": "UTC" into the user's saved plugin config, which + # then shadowed the real global timezone forever. + # _adapt_config_for_manager re-resolves it per league. # Re-derive scalar settings. self.enabled = self.config.get("enabled", getattr(self, "enabled", True)) @@ -674,12 +666,16 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) # Get display config from main config if available display_config = self.config.get("display", {}) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 3e0acda4..53406dad 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.8.3", + "version": "2.9.0", "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": [ + { + "released": "2026-07-29", + "version": "2.9.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect. Also stops the plugin writing a resolved timezone back into the saved config, which could persist a stale \"UTC\" override.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "2.8.3", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index ffc2057b..c1665623 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -19,6 +19,7 @@ from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from football_timezone import resolve_timezone # Imported at module load time on purpose (see the monorepo module-naming # rules): a deferred bare-name import could bind another plugin's # game_renderer after namespace isolation. @@ -687,22 +688,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/football-scoreboard/test_timezone_resolution.py b/plugins/football-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..3f684477 --- /dev/null +++ b/plugins/football-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for football_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/football-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import football_timezone # noqa: E402 +from football_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = football_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + football_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + football_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + football_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + football_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + football_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + football_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hockey-scoreboard/README.md b/plugins/hockey-scoreboard/README.md index ce63b366..c92e4f79 100644 --- a/plugins/hockey-scoreboard/README.md +++ b/plugins/hockey-scoreboard/README.md @@ -373,6 +373,9 @@ Make sure `enabled: true` in the configuration and the plugin is activated in th ## Troubleshooting **No games showing:** +- **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): + the plugin couldn't read your global timezone. Set `timezone` under the + plugin's Advanced Settings to your IANA zone, e.g. `America/Chicago`. - Check that at least one league is enabled in config - Verify the season is active for enabled leagues - Check `recent_games_hours` and `upcoming_games_hours` settings @@ -513,6 +516,13 @@ Games from all leagues will be mixed and sorted by: 2. Favorite teams (if enabled) 3. Start time +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## Data Source This plugin uses the **ESPN public API** for all hockey data: diff --git a/plugins/hockey-scoreboard/base_classes.py b/plugins/hockey-scoreboard/base_classes.py index 416cfdaa..92352ad7 100644 --- a/plugins/hockey-scoreboard/base_classes.py +++ b/plugins/hockey-scoreboard/base_classes.py @@ -13,6 +13,8 @@ from typing import Any, Dict, Optional import pytz + +from hockey_timezone import resolve_timezone import requests from PIL import Image, ImageDraw, ImageFont from requests.adapters import HTTPAdapter @@ -224,12 +226,17 @@ def _load_and_resize_logo(self, team_abbrev: str, logo_path: Path) -> Optional[I return None def _get_timezone(self): - """Get configured timezone.""" - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _extract_game_details_common(self, game_event: Dict) -> tuple: """Extract common game details from ESPN event.""" diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 64dea4fd..0a7ee5ec 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -9,6 +9,12 @@ "default": false, "description": "Enable or disable the hockey scoreboard plugin" }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "defaults": { "type": "object", "description": "Default settings that can be inherited by leagues (can be overridden per league)", diff --git a/plugins/hockey-scoreboard/hockey_timezone.py b/plugins/hockey-scoreboard/hockey_timezone.py new file mode 100644 index 00000000..cb8ef4d0 --- /dev/null +++ b/plugins/hockey-scoreboard/hockey_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the hockey scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the hockey scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index 6f6f0684..e661b224 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -42,6 +42,8 @@ NCAAWHockeyUpcomingManager, ) +from hockey_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) @@ -843,12 +845,16 @@ def resolve_non_favorite_live_duration() -> int: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) # Get display config from main config if available display_config = self.config.get("display", {}) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 9737231c..42b55aca 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.4.1", + "version": "1.5.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.5.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.4.1", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 98cf90c9..4f62f254 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -25,6 +25,7 @@ from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from hockey_timezone import resolve_timezone class SportsCore(ABC): @@ -612,29 +613,17 @@ def _fetch_odds(self, game: Dict) -> None: ) def _get_timezone(self): - timezone_name = None + """Timezone event start times are rendered in. - # Allow plugin-specific override first - timezone_name = self.config.get("timezone") - - if not timezone_name and self.config_manager: - try: - timezone_name = self.config_manager.get_timezone() - except Exception as exc: - self.logger.warning( - f"Error retrieving timezone from ConfigManager: {exc}" - ) - - if not timezone_name: - timezone_name = "UTC" - - try: - return pytz.timezone(timezone_name) - except pytz.UnknownTimeZoneError: - self.logger.warning( - f"Unknown timezone '{timezone_name}' - falling back to UTC" - ) - return pytz.utc + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/hockey-scoreboard/test_timezone_resolution.py b/plugins/hockey-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..e351611a --- /dev/null +++ b/plugins/hockey-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for hockey_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/hockey-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import hockey_timezone # noqa: E402 +from hockey_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = hockey_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + hockey_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + hockey_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + hockey_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + hockey_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + hockey_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + hockey_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/lacrosse-scoreboard/CHANGELOG.md b/plugins/lacrosse-scoreboard/CHANGELOG.md index 75fe9c43..04ead393 100644 --- a/plugins/lacrosse-scoreboard/CHANGELOG.md +++ b/plugins/lacrosse-scoreboard/CHANGELOG.md @@ -1,5 +1,14 @@ # Lacrosse Scoreboard — Changelog +## 1.5.0 (2026-07-29) + +### Fixed +- **Game start times shown in UTC**: The plugin read the LEDMatrix global timezone only from `cache_manager.config_manager`. On cores that hang `config_manager` off the plugin manager instead, that lookup came back empty and every start time was rendered in UTC, while plugins that check the plugin manager first (clock-simple, geochron) showed the correct local time on the same device. Timezone resolution now lives in `lacrosse_timezone.py` and tries, in order: the plugin's own `timezone` setting, `plugin_manager.config_manager`, `cache_manager.config_manager`, the host system zone (`TZ`, `/etc/timezone`, `/etc/localtime`), and only then UTC. +- **`timezone` setting was not in the config schema**, so it never appeared in the web UI. It is now a documented string property under Advanced Settings. + +### Added +- `timezone` (Advanced Settings): optional IANA zone override, e.g. `America/Chicago`. Blank (the default) follows the LEDMatrix global timezone. + ## 1.1.0 (2026-04-07) ### Breaking change — display modes renamed with `lax_` prefix diff --git a/plugins/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md index 1dde1ba0..a7fd3511 100644 --- a/plugins/lacrosse-scoreboard/README.md +++ b/plugins/lacrosse-scoreboard/README.md @@ -177,6 +177,13 @@ watch a game delayed and don't want the score spoiled. It uses the same full-name abbreviation format as `favorite_teams` (see below), and always wins if a team appears in both lists. +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## Team Abbreviations **Important — NCAA lacrosse uses full-name abbreviations, not the short codes you may be used to from the football, basketball, or hockey plugins.** ESPN's lacrosse feed returns team abbreviations like `NORTH CAROLINA`, `JOHNS HOPKINS`, `SAINT JOSEPH'S`, not `UNC` / `JHU` / `SJU`. Use the full-name form in `favorite_teams` or the matching will fail silently. @@ -245,6 +252,11 @@ Team logos are fetched from `https://a.espncdn.com/i/teamlogos/ncaa/500/{team_id ## Troubleshooting + + +- **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): + the plugin couldn't read your global timezone. Set `timezone` under the + plugin's Advanced Settings to your IANA zone, e.g. `America/Chicago`. **My favorite team doesn't show up.** You're almost certainly using a short abbreviation like `UNC` or `JHU`. Lacrosse abbreviations are the full school name in uppercase — see **Team Abbreviations** above. **No games appear at all.** NCAA lacrosse is a spring sport. Men's runs roughly January through late May; women's runs February through late May. Outside that window, the ESPN scoreboard endpoint returns an empty `events[]` array and the plugin has nothing to display. diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index ef3f9c49..15dc6317 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -9,6 +9,12 @@ "default": false, "description": "Enable or disable the lacrosse scoreboard plugin" }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "defaults": { "type": "object", "description": "Default settings that can be inherited by leagues (can be overridden per league)", diff --git a/plugins/lacrosse-scoreboard/lacrosse_timezone.py b/plugins/lacrosse-scoreboard/lacrosse_timezone.py new file mode 100644 index 00000000..9ea4307e --- /dev/null +++ b/plugins/lacrosse-scoreboard/lacrosse_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the lacrosse scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the lacrosse scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py index 581d4ffe..2c714e0d 100644 --- a/plugins/lacrosse-scoreboard/manager.py +++ b/plugins/lacrosse-scoreboard/manager.py @@ -53,6 +53,8 @@ NCAAWLacrosseUpcomingManager, ) +from lacrosse_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) @@ -797,12 +799,16 @@ def resolve_non_favorite_live_duration() -> int: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) # Get display config from main config if available display_config = self.config.get("display", {}) diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 8f4b3dca..d1c4fac4 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.4.1", + "version": "1.5.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.5.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.4.1", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 9f4483b6..1a1de173 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -25,6 +25,7 @@ from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from lacrosse_timezone import resolve_timezone class SportsCore(ABC): @@ -613,29 +614,17 @@ def _fetch_odds(self, game: Dict) -> None: ) def _get_timezone(self): - timezone_name = None + """Timezone event start times are rendered in. - # Allow plugin-specific override first - timezone_name = self.config.get("timezone") - - if not timezone_name and self.config_manager: - try: - timezone_name = self.config_manager.get_timezone() - except Exception as exc: - self.logger.warning( - f"Error retrieving timezone from ConfigManager: {exc}" - ) - - if not timezone_name: - timezone_name = "UTC" - - try: - return pytz.timezone(timezone_name) - except pytz.UnknownTimeZoneError: - self.logger.warning( - f"Unknown timezone '{timezone_name}' - falling back to UTC" - ) - return pytz.utc + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/lacrosse-scoreboard/test_timezone_resolution.py b/plugins/lacrosse-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..0ef03442 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for lacrosse_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/lacrosse-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import lacrosse_timezone # noqa: E402 +from lacrosse_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = lacrosse_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + lacrosse_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + lacrosse_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + lacrosse_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + lacrosse_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + lacrosse_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + lacrosse_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nrl-scoreboard/README.md b/plugins/nrl-scoreboard/README.md index 29eb6ecf..79daaf9f 100644 --- a/plugins/nrl-scoreboard/README.md +++ b/plugins/nrl-scoreboard/README.md @@ -101,6 +101,13 @@ options (see `config_schema.json` for the full list, types, and defaults): } ``` +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## License See `LICENSE`. diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 2f0a8d24..71d91a9d 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -340,6 +340,12 @@ "description": "Update interval for upcoming games (seconds)", "x-advanced": true }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "dynamic_duration": { "type": "object", "title": "NRL Dynamic Duration Settings", @@ -547,6 +553,7 @@ "live_update_interval", "recent_update_interval", "upcoming_update_interval", + "timezone", "dynamic_duration", "mode_durations", "background_service" diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index 0195c20e..3fe37cdf 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -41,6 +41,8 @@ # Import the NRL manager factory from nrl_managers import create_nrl_managers, LEAGUE_NAMES, NRL_LEAGUE_SLUG +from nrl_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) # NRL is a single league. Its ESPN league slug is "3" (see nrl_managers.py), but @@ -260,12 +262,16 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]: } } - # Global config - timezone from cache_manager's config_manager if available - timezone_str = cfg.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=cfg, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) display_config = cfg.get("display", {}) if not display_config and hasattr(self.cache_manager, 'config_manager'): diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 0b9c9fe3..9a5ac087 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.0.4", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-07-29", + "version": "1.1.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.0.4", diff --git a/plugins/nrl-scoreboard/nrl_timezone.py b/plugins/nrl-scoreboard/nrl_timezone.py new file mode 100644 index 00000000..df734029 --- /dev/null +++ b/plugins/nrl-scoreboard/nrl_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the NRL scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the NRL scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 2cad0e8e..13d49b61 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -19,6 +19,7 @@ from dynamic_team_resolver import DynamicTeamResolver from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from nrl_timezone import resolve_timezone # Import main logo downloader (same as football plugin) import sys @@ -786,22 +787,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/nrl-scoreboard/test_timezone_resolution.py b/plugins/nrl-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..52ab9699 --- /dev/null +++ b/plugins/nrl-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for nrl_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/nrl-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import nrl_timezone # noqa: E402 +from nrl_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = nrl_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + nrl_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + nrl_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + nrl_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + nrl_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + nrl_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + nrl_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index cdfd8cee..856dc3a5 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -32,6 +32,10 @@ A plugin for LEDMatrix that displays live, recent, and upcoming soccer games acr - `show_records`: Display team win-loss records (default: false) - `show_ranking`: Display team rankings when available (default: false) - `background_service`: Configure API request settings +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. ### Per-League Settings @@ -293,6 +297,9 @@ service. ## Troubleshooting +- **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): + the plugin couldn't read your global timezone. Set `timezone` under the + plugin's Advanced Settings to your IANA zone, e.g. `America/Chicago`. - **No games showing**: Check if leagues are enabled and API endpoints are accessible - **Missing team logos**: Ensure team logo files exist in your assets/sports/soccer_logos/ directory - **Slow updates**: Adjust the update interval in league configuration diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index cf3a6a14..2e37076e 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -98,6 +98,12 @@ "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode", "x-advanced": true }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "background_service": { "type": "object", "properties": { @@ -4940,6 +4946,7 @@ "upcoming_games_to_show", "show_favorite_teams_only", "game_display_duration", + "timezone", "background_service", "custom_leagues", "leagues" diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 06ef7f72..72fae961 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -64,6 +64,8 @@ create_custom_league_managers, ) +from soccer_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) # Predefined league keys and display names (priority 1-8) @@ -417,12 +419,16 @@ def _adapt_config_for_manager(self, league_key: str) -> Dict[str, Any]: } } - # Add global config - get timezone from cache_manager's config_manager if available - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) # Get display config from main config if available display_config = self.config.get("display", {}) @@ -659,11 +665,16 @@ def _adapt_config_for_custom_league(self, custom_league: Dict[str, Any]) -> Dict } # Add global config - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) display_config = self.config.get("display", {}) if not display_config and hasattr(self.cache_manager, 'config_manager'): diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index a75fa0bc..14a7ac55 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.3.1", + "version": "2.4.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-07-29", + "version": "2.4.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "2.3.1", diff --git a/plugins/soccer-scoreboard/soccer_timezone.py b/plugins/soccer-scoreboard/soccer_timezone.py new file mode 100644 index 00000000..896b804c --- /dev/null +++ b/plugins/soccer-scoreboard/soccer_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the soccer scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the soccer scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + ) diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index d803207c..a503eb68 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -19,6 +19,7 @@ from dynamic_team_resolver import DynamicTeamResolver from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from soccer_timezone import resolve_timezone # Import main logo downloader (same as football plugin) import sys @@ -779,22 +780,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/soccer-scoreboard/test_timezone_resolution.py b/plugins/soccer-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..41afbc3e --- /dev/null +++ b/plugins/soccer-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for soccer_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/soccer-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import soccer_timezone # noqa: E402 +from soccer_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = soccer_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + soccer_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + soccer_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + soccer_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + soccer_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + soccer_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + soccer_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ufc-scoreboard/README.md b/plugins/ufc-scoreboard/README.md index 245871e2..dc9ed65d 100644 --- a/plugins/ufc-scoreboard/README.md +++ b/plugins/ufc-scoreboard/README.md @@ -61,6 +61,13 @@ For the full set of nested keys (scroll tuning, display durations, update intervals, customization fonts/colors), see [`config_schema.json`](config_schema.json). +### Timezone + +- `timezone` (Advanced): IANA name used to display event start times, e.g. + `America/Chicago`. Leave blank (the default) to follow the LEDMatrix global + timezone; if that isn't set, the host system's timezone is used, and only if + neither is available do times fall back to UTC. + ## Fighter headshots On first display the plugin downloads fighter headshots into diff --git a/plugins/ufc-scoreboard/config_schema.json b/plugins/ufc-scoreboard/config_schema.json index 89ca1d58..1b098639 100644 --- a/plugins/ufc-scoreboard/config_schema.json +++ b/plugins/ufc-scoreboard/config_schema.json @@ -30,6 +30,12 @@ "maximum": 60, "description": "Duration in seconds to show each individual fight before rotating to the next fight within the same mode" }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set." + }, "ufc": { "type": "object", "title": "UFC Settings", diff --git a/plugins/ufc-scoreboard/manager.py b/plugins/ufc-scoreboard/manager.py index a5d9fe19..f01c2371 100644 --- a/plugins/ufc-scoreboard/manager.py +++ b/plugins/ufc-scoreboard/manager.py @@ -36,6 +36,8 @@ ScrollDisplayManager = None SCROLL_AVAILABLE = False +from ufc_timezone import resolve_timezone_name + logger = logging.getLogger(__name__) @@ -314,11 +316,16 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: } # Add global config - timezone_str = self.config.get("timezone") - if not timezone_str and hasattr(self.cache_manager, "config_manager"): - timezone_str = self.cache_manager.config_manager.get_timezone() - if not timezone_str: - timezone_str = "UTC" + # Resolve timezone: plugin override -> global config (either manager) + # -> host system zone -> UTC. Reading only cache_manager.config_manager + # used to fall through to UTC on cores that expose it via the plugin + # manager instead, rendering every start time in UTC. + timezone_str = resolve_timezone_name( + config=self.config, + plugin_manager=getattr(self, "plugin_manager", None), + cache_manager=self.cache_manager, + log=self.logger, + ) display_config = self.config.get("display", {}) if not display_config and hasattr(self.cache_manager, "config_manager"): diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index ea5c4e56..d4eb9e1e 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.2.4", + "version": "1.3.0", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "released": "2026-07-29", + "version": "1.3.0", + "notes": "Fix event start times rendering in UTC. The plugin looked for the LEDMatrix global timezone only on cache_manager.config_manager; on cores that expose config_manager via the plugin manager instead, that lookup found nothing and every start time was drawn in UTC even though the clock plugin was correct on the same device. Timezone resolution now checks the plugin override, then both config managers, then the host system zone, before falling back to UTC. Adds a documented timezone setting under Advanced Settings -- previously the key was not in the config schema, so hand-editing it had no effect. Also fixes the plugin failing to load at all: sports.py imported the bare name `logo_downloader`, but ufc-scoreboard ships no such module -- it only ever resolved by binding another plugin's copy off sys.path, so loading raised ModuleNotFoundError whenever no such plugin happened to be loaded. It now imports src.logo_downloader from the core, matching the soccer/nrl/afl plugins.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-05-15", "version": "1.2.4", diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index 8398bbdc..5b698a81 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -19,9 +19,23 @@ from dynamic_team_resolver import DynamicTeamResolver except ImportError: DynamicTeamResolver = None -from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +from ufc_timezone import resolve_timezone + +# Import main logo downloader (same as football plugin). +# This used to be a bare `from logo_downloader import ...`, but ufc-scoreboard +# ships no logo_downloader.py -- the name only ever resolved by binding some +# *other* plugin's copy off sys.path, so loading this plugin failed outright +# when no such plugin happened to be loaded. Sibling plugins (soccer, nrl, afl) +# and this file's own deferred import below both use the src.* path. +import sys +# Add parent directory to path to import from src +plugin_dir = Path(__file__).resolve().parent +project_root = plugin_dir.parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) +from src.logo_downloader import LogoDownloader, download_missing_logo class SportsCore(ABC): @@ -621,22 +635,17 @@ def fetch_odds(): ) def _get_timezone(self): - """Get timezone from config, with fallback to cache_manager's config_manager.""" - try: - # First try plugin config - timezone_str = self.config.get("timezone") - # If not in plugin config, try to get from cache_manager's config_manager - if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): - timezone_str = self.cache_manager.config_manager.get_timezone() - # Final fallback to UTC - if not timezone_str: - timezone_str = "UTC" - - self.logger.debug(f"Using timezone: {timezone_str}") - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - return pytz.utc + """Timezone event start times are rendered in. + + Normally the plugin manager has already resolved this and passed it down + in ``config['timezone']``; the shared resolver re-derives it from the + core config or the host system if it hasn't. + """ + return resolve_timezone( + config=self.config, + cache_manager=getattr(self, "cache_manager", None), + log=self.logger, + ) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """Check if we should log a warning based on cooldown period.""" diff --git a/plugins/ufc-scoreboard/test_timezone_resolution.py b/plugins/ufc-scoreboard/test_timezone_resolution.py new file mode 100644 index 00000000..172e18f0 --- /dev/null +++ b/plugins/ufc-scoreboard/test_timezone_resolution.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for ufc_timezone.resolve_timezone_name -- the resolution order that +decides which zone game start times are rendered in. + +Regression under test: the plugin used to read the global timezone only from +``cache_manager.config_manager``. On cores that hang ``config_manager`` off the +plugin manager instead, that lookup found nothing and every start time was +drawn in UTC, while plugins that check ``plugin_manager`` first (clock-simple, +geochron) showed the correct local time on the same device. + +Run: /bin/python plugins/ufc-scoreboard/test_timezone_resolution.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import ufc_timezone # noqa: E402 +from ufc_timezone import resolve_timezone, resolve_timezone_name # noqa: E402 + + +class _ConfigManager: + """Core ConfigManager stand-in exposing get_timezone().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def get_timezone(self): + return self._timezone + + +class _LegacyConfigManager: + """Older core: no get_timezone(), only load_config().""" + + def __init__(self, timezone=None): + self._timezone = timezone + + def load_config(self): + return {"timezone": self._timezone} + + +class _CountingConfigManager: + """Records how many times the core was asked for the timezone.""" + + def __init__(self, timezone=None): + self._timezone = timezone + self.calls = 0 + + def get_timezone(self): + self.calls += 1 + return self._timezone + + +class _BrokenConfigManager: + """Core whose get_timezone() blows up -- must not take the plugin down.""" + + def get_timezone(self): + raise RuntimeError("config not loaded") + + +class _Holder: + """Stands in for a plugin_manager / cache_manager.""" + + def __init__(self, config_manager=None): + if config_manager is not None: + self.config_manager = config_manager + + +# Kept so tests that stub system-zone detection can restore it, and so the +# results don't depend on the machine the suite runs on. +_real_system_timezone_name = ufc_timezone.system_timezone_name + + +def test_plugin_config_override_wins(): + name = resolve_timezone_name( + config={"timezone": "America/Denver"}, + plugin_manager=_Holder(_ConfigManager("America/New_York")), + cache_manager=_Holder(_ConfigManager("Europe/London")), + ) + assert name == "America/Denver", name + print("✓ explicit plugin-level timezone wins") + + +def test_lower_priority_sources_are_not_evaluated(): + """_get_timezone() runs per event; once a candidate resolves, the + remaining sources must not be touched.""" + plugin_cm = _CountingConfigManager("America/New_York") + cache_cm = _CountingConfigManager("Europe/London") + system_calls = [] + + ufc_timezone.system_timezone_name = lambda: system_calls.append(1) or None + try: + name = resolve_timezone_name( + config={"timezone": "America/Chicago"}, + plugin_manager=_Holder(plugin_cm), + cache_manager=_Holder(cache_cm), + ) + finally: + ufc_timezone.system_timezone_name = _real_system_timezone_name + + assert name == "America/Chicago", name + assert plugin_cm.calls == 0, plugin_cm.calls + assert cache_cm.calls == 0, cache_cm.calls + assert system_calls == [], system_calls + print("✓ resolution stops at the first valid source") + + +def test_plugin_manager_config_manager_is_consulted(): + """The regression: cache_manager has no config_manager at all.""" + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_ConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from plugin_manager.config_manager") + + +def test_cache_manager_config_manager_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from cache_manager.config_manager") + + +def test_legacy_load_config_fallback(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")), + cache_manager=_Holder(), + ) + assert name == "America/Chicago", name + print("✓ global timezone read from legacy load_config()") + + +def test_raising_config_manager_falls_through(): + name = resolve_timezone_name( + config={}, + plugin_manager=_Holder(_BrokenConfigManager()), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ a raising get_timezone() falls through instead of propagating") + + +def test_blank_and_invalid_values_are_skipped(): + name = resolve_timezone_name( + config={"timezone": " "}, + plugin_manager=_Holder(_ConfigManager("Not/AZone")), + cache_manager=_Holder(_ConfigManager("America/Chicago")), + ) + assert name == "America/Chicago", name + print("✓ blank and invalid timezone values are skipped") + + +def test_system_timezone_backstop(): + ufc_timezone.system_timezone_name = lambda: "America/Chicago" + try: + name = resolve_timezone_name(config={}, plugin_manager=_Holder(), cache_manager=_Holder()) + finally: + ufc_timezone.system_timezone_name = _real_system_timezone_name + assert name == "America/Chicago", name + print("✓ system timezone used when no config_manager is reachable") + + +def test_utc_last_resort(): + ufc_timezone.system_timezone_name = lambda: None + try: + name = resolve_timezone_name(config={}, plugin_manager=None, cache_manager=None) + finally: + ufc_timezone.system_timezone_name = _real_system_timezone_name + assert name == "UTC", name + print("✓ falls back to UTC when every source is unavailable") + + +def test_resolve_timezone_returns_tzinfo_and_converts(): + from datetime import datetime + + import pytz + + tz = resolve_timezone(config={"timezone": "America/Chicago"}) + # 2026-07-28 23:45Z is a 6:45pm CDT start -- the symptom this fixes: + # a Central-time event rendering as 11:45PM. + utc_start = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC) + local = utc_start.astimezone(tz) + assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM", local + print("✓ resolve_timezone() converts a UTC start time to local") + + +def test_system_timezone_name_is_a_string_or_none(): + value = _real_system_timezone_name() + assert value is None or isinstance(value, str), value + print(f"✓ system_timezone_name() -> {value!r}") + + +def main(): + tests = [ + test_plugin_config_override_wins, + test_lower_priority_sources_are_not_evaluated, + test_plugin_manager_config_manager_is_consulted, + test_cache_manager_config_manager_fallback, + test_legacy_load_config_fallback, + test_raising_config_manager_falls_through, + test_blank_and_invalid_values_are_skipped, + test_system_timezone_backstop, + test_utc_last_resort, + test_resolve_timezone_returns_tzinfo_and_converts, + test_system_timezone_name_is_a_string_or_none, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} timezone resolution tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ufc-scoreboard/ufc_timezone.py b/plugins/ufc-scoreboard/ufc_timezone.py new file mode 100644 index 00000000..3931222e --- /dev/null +++ b/plugins/ufc-scoreboard/ufc_timezone.py @@ -0,0 +1,162 @@ +"""Timezone resolution for the UFC scoreboard plugin. + +Event start times arrive from ESPN in UTC and have to be converted to the user's +local zone before they are drawn. This module owns the "which zone?" decision so +every render path and the plugin manager all agree. + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +this plugin fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Module name is plugin-prefixed on purpose -- several plugins ship identically +named top-level modules and the core loads them as bare names (see +``scripts/check_module_collisions.py``). +""" + +import logging +import os +from typing import Any, Dict, Optional + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull a timezone name out of a core ConfigManager, if it offers one.""" + if config_manager is None: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + # Older cores expose the raw config instead of a get_timezone() helper. + for loader_name in ("load_config", "get_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() or {} + name = main_config.get("timezone") + if name: + return name + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + """ + log = log or logger + + def candidates(): + """Yield (source, name) lazily. + + The per-event ``_get_timezone()`` helper runs this once per event, and + the answer + is almost always the first candidate. Evaluating the sources on demand + keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield "plugin config", (config or {}).get("timezone") + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + for source, name in candidates(): + if not isinstance(name, str): + continue + name = name.strip() + if not name: + continue + try: + pytz.timezone(name) + except Exception: + log.warning("Ignoring invalid timezone %r from %s", name, source) + continue + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "Could not determine a timezone from the plugin config, the LEDMatrix " + "config or the system; game times will be shown in UTC. Set a timezone " + "in the UFC scoreboard's Advanced Settings to override." + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + ) + )