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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/baseball-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 10 additions & 0 deletions plugins/baseball-scoreboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions plugins/baseball-scoreboard/baseball_timezone.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
6 changes: 6 additions & 0 deletions plugins/baseball-scoreboard/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 3 additions & 7 deletions plugins/baseball-scoreboard/game_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
73 changes: 34 additions & 39 deletions plugins/baseball-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
NCAABaseballUpcomingManager,
)
from milb_managers import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager
from baseball_timezone import resolve_timezone_name

# Import scroll display components
try:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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'):
Expand All @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion plugins/baseball-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading