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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-07-28",
"last_updated": "2026-07-29",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down 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 All @@ -101,7 +101,7 @@
"last_updated": "2026-07-17",
"verified": true,
"screenshot": "",
"latest_version": "1.7.1"
"latest_version": "1.8.0"
},
{
"id": "calendar",
Expand Down Expand Up @@ -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",
Expand All @@ -240,7 +240,7 @@
"last_updated": "2026-07-17",
"verified": true,
"screenshot": "",
"latest_version": "2.8.3"
"latest_version": "2.9.0"
},
{
"id": "geochron",
Expand Down Expand Up @@ -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"
},
{
Expand All @@ -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"
},
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
{
Expand Down Expand Up @@ -1023,7 +1023,7 @@
"downloads": 0,
"verified": true,
"screenshot": "",
"latest_version": "1.0.2",
"latest_version": "1.1.0",
"last_updated": "2026-07-17"
},
{
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/afl-scoreboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions plugins/afl-scoreboard/afl_timezone.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
7 changes: 7 additions & 0 deletions plugins/afl-scoreboard/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -938,6 +944,7 @@
"celebration_enabled",
"celebration_duration",
"celebrate_opponent_goals",
"timezone",
"game_limits",
"display_options",
"filtering",
Expand Down
18 changes: 12 additions & 6 deletions plugins/afl-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'):
Expand Down
8 changes: 7 additions & 1 deletion plugins/afl-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading