mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(errors): record the exception's own stack trace record_error() called traceback.format_exc(), which only sees an exception while its except block is running. plugin_executor records exceptions caught on a worker thread after that block has ended, so every trace on /errors read "NoneType: None". The trace is now built from the exception's __traceback__. The executor's log call had the same problem with exc_info=True and now passes the exception. record_error() also merged LEDMatrixError context into the caller's dict in place; it now works on a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(wifi): point at configure_wifi_permissions.sh instead of a sudoers list The module docstring told users to grant NOPASSWD sudo on iptables and ip. configure_wifi_permissions.sh refuses those grants on purpose: a wildcard rule for either runs an arbitrary program as root. Point at the script and say why it leaves them out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): disconnect finds the saved profile by SSID disconnect_from_network() asked `nmcli -f NAME,802-11-wireless.ssid connection show` for the profile to take down, but nmcli rejects that column for `connection show`, so the lookup always failed and only the device was disconnected. The per-profile lookup _connect_nmcli() already used is now _find_profile_for_ssid(), and both callers share it. It also splits terse output on the last colon and unescapes "\:", so a profile name containing a colon is found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): write wifi_config.json atomically and report a failed save _save_config() opened the file for writing in place and swallowed any error, so a wifi_config.json left owned by root made the web toggle for auto-enabling AP mode report success while nothing was saved, and a crash mid-write could truncate the file. It now uses atomic_write_json, which also keeps the file's owner and shared group when root saves it, and returns False on failure. POST /wifi/ap/auto-enable answers 500 in that case. The file is now written with indent=4, like the other config files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve plugin:// fonts in the plugin's own directory FontManager looked for a plugin's bundled fonts under Path("plugins") / plugin_id: relative to the process cwd, and not the default install directory (plugin-repos/), so a manifest's plugin:// fonts never loaded. register_plugin_fonts() takes an optional plugin_dir, and PluginManager passes the directory it loaded the plugin from. Callers that omit it get a lookup in the configured plugin_system.plugins_directory, then plugins/, resolved against the install root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api-helper): cache responses for the requested cache_ttl APIHelper.get(cache_ttl=...) and set_cache(ttl=...) dropped the ttl on the claim that CacheManager does not support one, but CacheManager.set() takes a ttl, stores it with the entry, and both cache tiers honour it over a reader's max_age. Without it every response expired after the 300-second default read age, whatever the plugin asked for. The ttl is now passed through, and the cache read passes cache_ttl as max_age for entries written without one. The class docstring describes what the helper actually does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(style): one scale range for the schema, element_scale and LogoHelper The generated Scale field allowed 0.1 to 10, element_style's reader capped at 10 with no floor, and LogoHelper accepted 0.05 to 8 and reset anything else to 1.0. A logo scale of 9, which the form accepts, drew at the shipped size. MIN_ELEMENT_SCALE / MAX_ELEMENT_SCALE (0.1, 10.0) in src.element_style are now the schema bounds and the clamp every reader applies through coerce_scale(): a positive number outside the range is clamped, and anything that is not a finite positive number means the default. That also stops element_scale() passing NaN through, since min(nan, 10.0) is nan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(logos): placeholder lands at the requested path; empty logos list download_missing_logo() wrote its fallback placeholder to <normalize_abbreviation(abbr)>.png in the logo directory rather than to the logo_path the caller passed, so it could return True while nothing existed where the plugin looks (e.g. "TA&M.png" vs "TAANDM.png"). create_placeholder_logo() takes an optional filepath, and download_missing_logo passes the requested one. download_missing_logo_for_team() only caught KeyError, so a team whose "logos" list is empty raised IndexError; it now treats KeyError, IndexError and TypeError as "no logo URL". The placeholder is drawn with PLACEHOLDER_SIZE / PLACEHOLDER_BG, the constants is_placeholder_logo() recognises it by, instead of repeated literals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve bundled font paths against the install root TextHelper's default font_dir, the logo placeholder's font and FontManager's font_overrides.json were all relative to the process cwd, so a process started anywhere but the install root (the plugin safety harness, a manual run, a unit without WorkingDirectory) drew with PIL's default face and read no overrides. They now go through font_layout.resolve_asset_path; the overrides file sits in the install root's config/. The resolver docstrings described an order the code does not follow: resolve_asset_path never consults the cwd, and sports_shared's _resolve_font_path tries the cwd first. Both docstrings now say what the code does, and _resolve_font_path calls resolve_asset_path instead of probing FontManager for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): the web UI reads the sync status file the display writes sync_manager writes its status to tempfile.gettempdir(), but GET /api/v3/sync/status read a hardcoded /tmp/led_matrix_sync_status.json and defaulted the port to a literal 5765. Wherever TMPDIR is set (or on any non-/tmp host) the page only ever showed "starting". The endpoint now uses sync_manager.STATUS_FILE and SYNC_PORT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(http): the rankings resolver sends the project's User-Agent DynamicTeamResolver fetched ESPN rankings with a bare requests.get, so it sent python-requests' default User-Agent, which ESPN rejects; the AP_TOP_N favourites then resolved to nothing. It now sends DEFAULT_HTTP_HEADERS. BaseOddsManager carried its own copy of the User-Agent string and now uses the same shared headers (which also adds Accept-Language). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(backup): record the core release and read the configured plugin dir The manifest's ledmatrix_version came from a VERSION file that does not exist, then from .git/HEAD: a 12-character sha, or "ref: refs/he" when the branch's ref was packed. It is now src.__version__. list_installed_plugins() scanned a hardcoded plugin-repos/, so on an install whose plugin_system.plugins_directory points elsewhere, plugins missing from plugin_state.json were left out of the backup. It now reads the configured directory from config/config.json, defaulting to plugin-repos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(startup): report a missing display section once A config without a display section produced three errors for the one problem ("Missing required configuration key: display", "Display configuration is missing or empty" and "Display configuration is missing"), and an empty one produced two. _validate_config now reports it once, as a missing key or an empty section, and _validate_display_config leaves it to that. The module docstring said the validator fails fast; nothing in the display service calls raise_on_errors(), so it now says the errors are reported and startup continues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(wifi): share the copied blocks and name the AP constants - _parse_nmcli_wifi_list() is the one parser behind _scan_nmcli and _scan_nmcli_cached. - _verify_connected(), _wait_for_device_idle(), _failsafe_ap() and _mark_forced() replace blocks that were pasted two or three times in the connect and enable-AP paths. The device-idle wait now checks before its first one-second sleep instead of after it. - _check_command() calls _find_command_path() instead of repeating it. - AP_IP, PORTAL_PORT, AP_PROFILE_NAME and AP_PROFILE_NAMES name values that were spelled out 14, 12, 8 and 2 times; the two deletion loops now walk the same tuple. The iwconfig status path compares the AP address exactly: startswith() also skipped 192.168.4.10-19. - Dropped a second WIFI.SIGNAL query that repeated the first, a no-op "if ssid: continue", the try/except around _connect_wpa_supplicant's constant return, and a second save of a scan scan_networks already saves. - _ensure_wifi_radio_enabled's docstring says it returns True when the radio state cannot be read at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop dead branches and history comments in ConfigManager - The module docstring pointed plugin authors at update_plugin_config(), which does not exist; it now names save_config_atomic() and save_raw_file_content(). - load_config's FileNotFoundError handler tested the message for "config_secrets.json", but a missing secrets file is handled where it is read, so only config.json reaches it; the check is gone. - save_raw_file_content's `file_type == "main" or "secrets"` guard was always true (anything else raised earlier). - get_raw_file_content('secrets') already returns {} for a missing file, so the os.path.exists() in front of two calls to it is gone. - Comments that narrated earlier behaviour are rewritten as what the code does now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(background-data): present-tense comments, drop unused API - Comments that told the history of each fix (what "used to" happen, "the old per-delivery release") now state the invariant the code keeps. - get_statistics() no longer reports a constant 'queue_size': 0, and the uncalled clear_completed_requests() is gone (_cleanup_completed_requests does that job on every completion). Neither is referenced in core, the web UI or the plugin monorepo. shutdown_background_service() has no production caller either, but it is the only way to tear down the get_background_service() singleton, which the tests rely on, so it stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(odds): drop the unread cache_ttl and merge the odds_data branches BaseOddsManager loaded base_odds_manager.cache_ttl from config and never used it: cached odds live for the update interval (get_odds' ttl=interval). No core or monorepo code reads the attribute, so it is gone along with its log line. The two consecutive `if odds_data:` blocks are one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(backup): one table for the single-file sections config, secrets, wifi and ytm_auth were each spelled out in create, preview, validate and restore. _SINGLE_FILE_SECTIONS lists them once, with the RestoreOptions flag that restores each, and all four walk it. Restore error messages keep their wording ("Failed to restore <file name>"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(fonts): drop FontManager's write-only state and duplicate logs - fonts_config, font_metadata and font_dependencies were written and never read; the performance_stats keys font_load_times, render_times, total_renders and the per-call "resolve" timings (_record_performance_metric) likewise. get_performance_stats() reads only the counters that remain. Nothing in core or the plugin monorepo references any of them. - A failed BDF load was logged twice, by _load_bdf_font and again by get_font; get_font's line is the one kept. - Removed "NEW:" and commented-out cozette entries, the "Copy font to assets/fonts" comment on code that copies nothing, and local imports of names the module already imports. The deprecated add_font() now resolves assets/fonts against the install root. The @deprecated methods stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(text-helper): cache loaded fonts; drop the pre-textlength fallback TextHelper declared _font_cache, cleared it and reported its size, but never stored anything in it. load_fonts() now keeps each (file, size) it loads there, so clear_font_cache() and get_font_cache_stats() mean what they say and repeated load_fonts() calls reuse the fonts. get_text_width() no longer catches AttributeError for Pillow releases without ImageDraw.textlength; requirements.txt pins Pillow>=12.2. The class docstring describes what the helper does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(common): fix wrong docstrings in api_helper, permission_utils, snapshot_policy - permission_utils called 0o2775 "sticky bit"; the 2 is setgid, which is what makes new files take the directory's group. - snapshot_policy pointed at web_interface/blueprints/api_v3.py, which is a package now; the health check is in api_v3/misc.py. - APIHelper.clear_cache() lost a history note and a fallback to a clear() method that neither CacheManager nor the testing MockCacheManager has. The session headers are built from DEFAULT_HTTP_HEADERS instead of a copy of them, and the module docstring says what the module offers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sports): present-tense comments in the shared scoreboard renderers - sports_scroll and sports_game_renderer comments that referred to "this PR", "the old flat 128px card" or what the renderer "previously" did now describe the current behaviour and its reason. - The block explaining why non-finite settings are rejected sat above _score_reserve_width; it describes _center_gap_width and now lives in it. - unshare_element_fonts wrapped its import of font_layout.load_truetype in an `except ImportError` that cannot fire inside core; the import stays at call time so tests can spy on the pinned loader. - sports_card docstrings that told the history of a fix say what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports-shared): drop dead code, name the ESPN limit - _get_weeks_data asked for limit=1000, which fetch_espn_scoreboard clamps to ESPN_MAX_LIMIT anyway; it now names that constant. Its unused `immediate_events = []` is gone. - _get_season_schedule_dates() returned ("", "") and has no caller in core or the plugin monorepo. - _should_log keeps its warning_type parameter (part of the inherited signature, though nothing in core or the monorepo calls it) and its docstring says the cooldown is shared across types. - An unused ImageFont import is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sync): one follower-mode switch, shared panel defaults - The class docstring said the leader sends PNG frames. Frames go over UDP as raw RGB; PNG is only the Vegas scroll image sent over TCP. It now describes both paths. - _enter_follower_mode() replaces the two copies of "note the leader, switch from standalone to follower, log, write status" in the frame and scroll-position handlers. - The rows/cols fallbacks use DEFAULT_ROWS / DEFAULT_COLS from src.display_geometry, as chain_length already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(style): drop _layout_axis, name the layout group title - ElementStyleResolver._layout_axis() had no caller in core or the plugin monorepo. - _element_block_from_spec checked spec['size'] was a dict again after size_spec already had; it reads size_spec. - The "Layout Offsets" title written into three generated schema blocks is _LAYOUT_TITLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(logo-helper): say what the placeholder draws; name the 1.5 box factor - _create_placeholder_logo's docstring said it draws the team abbreviation; it draws an outlined grey box and nothing else. The docstring says so, and the "in a real implementation you'd want text" comments are gone. - The 1.5 x panel default logo box, written out six times, is DEFAULT_LOGO_BOX_FACTOR. - ImageDraw is imported with Image at the top of the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(logos): drop dead code and a duplicate regex in logo_downloader - _SAFE_LEAGUE_CODE_RE was the same pattern as _SAFE_LEAGUE_RE; both checks use the one. - get_logo_filename_variations reassigned the TA&M case to the list it already had; the function returns the two names directly. - _get_team_name_variations() had no caller in core or the plugin monorepo. - fetch_single_team's docstring was copied from fetch_teams_data; a log message read "for{team_id}". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop the Pillow<9.1 resample shim and a catch-and-reraise - adaptive_images fell back to Image.LANCZOS/NEAREST for Pillow < 9.1; requirements.txt pins Pillow>=12.2. RESAMPLE_LANCZOS and RESAMPLE_NEAREST keep their names (src.common re-exports them). - CacheManager.save_cache caught CacheError only to re-raise it; the disk write is now called directly, with the same result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api-helper): stop the real CacheManager's cleanup thread The cache-lifetime tests built a CacheManager and left its cleanup thread's class-wide claim on the directory in place, which broke test_cache_cleanup_thread_ownership when it ran later in the session. The fixture now stops the thread on teardown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): core-common Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1398 lines
67 KiB
Python
1398 lines
67 KiB
Python
"""The sports.py surface that is byte-identical in every scoreboard.
|
|
|
|
Nine plugins ship their own ``sports.py`` -- 41,326 lines in total. Comparing
|
|
executable ASTs across the eight that share a lineage, 48 method bodies are
|
|
byte-identical in all eight: 1,007 lines carried in eight copies, so 8,056
|
|
duplicated lines that must be edited eight times to fix once.
|
|
|
|
They are the parts with no sport in them. The selection and rotation engine
|
|
(``_round_robin_favorites``, ``_favorites_first``, ``_compose_selection``,
|
|
``_check_ranking_coverage``, ``_game_divisions``, ``_normalise_quality``), the
|
|
font/colour/date subsystem (``_scale_headline_fonts``, ``_scorebug_font``,
|
|
``_resolve_font_size``, ``_format_game_date``, ``_font_color``), and the
|
|
switch-mode upcoming card (``_draw_upcoming_center_switch``). Nothing here knows
|
|
what an inning or a possession is.
|
|
|
|
Mixins rather than free functions, because every one of these reads host state
|
|
-- ``self.config``, ``self.fonts``, ``self.logger``, ``self.display_width``.
|
|
Rewriting 48 bodies into free functions would be a rewrite, not a move; as
|
|
mixins the bodies move verbatim, which is what keeps the renders identical.
|
|
|
|
THREE OF THE 48 ARE DELIBERATELY LEFT BEHIND
|
|
--------------------------------------------
|
|
Byte-identical bodies are not automatically safe to move: a body can bind a
|
|
module-level name that differs per plugin, and then it only *looks* the same.
|
|
|
|
- ``_get_timezone`` calls ``resolve_timezone``, imported from a per-plugin
|
|
module (``hockey_timezone``, ``soccer_timezone``, ...). All eight of those
|
|
differ -- each carries its own ``_WRITEBACK_FIXED_IN`` version -- so moving
|
|
the caller here would silently bind every scoreboard to one plugin's copy.
|
|
- ``_extract_game_details`` and ``_fetch_data`` are ``@abstractmethod`` stubs.
|
|
They are the sport-specific contract; satisfying them from a mixin would let a
|
|
plugin instantiate without implementing its own sport.
|
|
|
|
``_resolve_font_path`` went the other way: it is a module-level function in
|
|
sports.py rather than a method, identical in all eight, and ``_scale_headline_fonts``
|
|
needs it -- so it is inlined below rather than left behind.
|
|
|
|
WHAT A HOST MUST PROVIDE
|
|
------------------------
|
|
Enumerated by walking every ``self.<attr>`` the mixins read and subtracting what
|
|
they define, so this list is derived rather than remembered. Everything below is
|
|
supplied by all eight scoreboards today.
|
|
|
|
State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``,
|
|
``display_manager``, ``league``, ``sport``, ``mode_config``, ``session``,
|
|
``headers``, ``favorite_teams``, ``games_list``, ``current_game_index``,
|
|
``last_game_switch``, ``last_update``, ``update_interval``,
|
|
``no_data_interval``, ``game_display_duration``, ``stale_game_timeout``,
|
|
``other_games_min_quality``, ``schedule_lookback_days``,
|
|
``schedule_lookahead_days``, ``game_update_timestamps``,
|
|
``_zero_clock_timestamps``, ``_logo_cache``, ``_selection_pools``,
|
|
``_ranking_coverage_logged_at``, ``_empty_live_streak``, ``_last_warning_time``,
|
|
``_score_grew``.
|
|
|
|
Methods that stay per-plugin, because they are not identical across the eight
|
|
(or, for ``_get_timezone``, because they bind per-plugin modules):
|
|
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
|
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
|
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
|
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
|
|
|
|
Of the fourteen shared class constants, thirteen are identical everywhere and
|
|
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
|
a side and override it, the same two that override ``_SCORE_PROBE`` on
|
|
``SportsGameRendererMixin``.
|
|
|
|
TWINS IN sports_card
|
|
--------------------
|
|
Many of these have same-named twins in ``src/common/sports_card.py``, which the
|
|
scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls
|
|
each pair with the same inputs (the plugins' fixture games in every payload
|
|
shape, plus edge cases) and splits them in two:
|
|
|
|
- Identical: ``_card_option``, ``_vs_text``, ``_format_game_time``,
|
|
``_coerce_rgb``, ``_crisp_size``, ``_unshare_element_fonts`` (given the same
|
|
element map) and the constant tables. These are now thin wrappers over the
|
|
``sports_card`` function; ``_format_game_date`` and ``_schema_font_size``
|
|
share its body/parser while keeping their own setting, zone and cache.
|
|
``_resolve_font_size`` agrees too but keeps its body, because it dispatches
|
|
through the overridable ``_schema_font_size``/``_crisp_size``.
|
|
- Different, and pinned as they are: ``_side_is_favorite`` /
|
|
``_favorite_result`` / ``_recent_score_color`` (flat keys and the host's
|
|
favourites only), ``_weekday_for`` (the plugin's resolved zone, not
|
|
``config["timezone"]``), ``_font_color`` / ``_ELEMENT_FOR_FONT`` (another
|
|
element vocabulary), ``_element_color`` (passes ``SKIN_MODE``). Each shows
|
|
up in one display mode only, so which side is right is a product decision;
|
|
the test that pins it names the difference.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
|
|
|
import pytz
|
|
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
|
import requests
|
|
from PIL import Image, ImageDraw
|
|
from src.common import sports_card as _card
|
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# How long a live mode may stretch its poll interval when nothing is happening,
|
|
# and the streak lengths that earn each stretch. Identical in all eight plugins.
|
|
_IDLE_SHORT_STREAK = 6
|
|
_IDLE_SHORT_FACTOR = 2
|
|
_IDLE_LONG_STREAK = 24
|
|
_IDLE_LONG_FACTOR = 6
|
|
_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
|
|
|
|
#: How long after a scheduled start to keep looking on the live cadence. ESPN
|
|
#: does not flip a game to in-progress exactly at kickoff, and an escalated
|
|
#: back-off treats each of those early looks as another empty one.
|
|
_KICKOFF_GRACE_SECONDS = 900
|
|
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
|
_KICKOFF_POLL_FLOOR = 30
|
|
|
|
|
|
def _resolve_font_path(path: str) -> str:
|
|
"""Resolve a bundled font path without depending on the process cwd.
|
|
|
|
These fonts ship with the LEDMatrix core, and every call site here named
|
|
them relative to the working directory. That holds under the packaged
|
|
systemd unit, whose WorkingDirectory is the install root, and breaks
|
|
everywhere else -- the plugin safety harness, a manual run from $HOME, a
|
|
unit file written without WorkingDirectory. The failure is quiet: the
|
|
load raises, the caller falls back, and the scoreboard renders in PIL's
|
|
default face instead of the pixel font it was laid out for.
|
|
|
|
Resolution order: the path as given, relative to the cwd, when it
|
|
exists -- the order the scoreboards' own sports.py copies used, so a
|
|
process running from another checkout keeps that checkout's fonts --
|
|
then :func:`src.common.font_layout.resolve_asset_path` (the install
|
|
root), which returns the original string when neither exists so callers
|
|
still raise and fall back.
|
|
"""
|
|
if os.path.exists(path):
|
|
return path
|
|
return resolve_asset_path(path)
|
|
|
|
|
|
class SportsCoreSharedMixin:
|
|
"""The ``SportsCore`` bodies identical in all eight scoreboards."""
|
|
|
|
#: Design height the font scale is expressed against.
|
|
_FONT_DESIGN_HEIGHT: ClassVar[int] = 32
|
|
#: Fraction of the centre strip a score may grow into.
|
|
_SCORE_GROWTH_BUDGET: ClassVar[float] = 0.65
|
|
#: Widest score the scorebug sizes itself to hold. Leagues that reach three
|
|
#: digits a side override this with "000-000".
|
|
_SCORE_PROBE_TEXT: ClassVar[str] = "00-00"
|
|
#: Whether this sport's scorebug draws a score at all.
|
|
_DRAWS_SCORE: ClassVar[bool] = False
|
|
#: Fallback (font, size) rungs for a score that will not fit.
|
|
_NARROW_SCORE_RUNGS: ClassVar[Tuple[Tuple[str, int], ...]] = (
|
|
("4x6-font.ttf", 14), ("4x6-font.ttf", 7))
|
|
#: Hard ceiling on score growth, in multiples of the configured size.
|
|
_SCORE_MAX_GROWTH: ClassVar[int] = 2
|
|
#: Which colour setting owns each font slot.
|
|
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
|
|
"score": "score_text", "time": "period_text", "team": "team_text",
|
|
"detail": "detail_text", "status": "status_text"}
|
|
# The tables below are sports_card's (and font_layout's) values. The dicts
|
|
# are copies, so a caller that mutates one module's table -- or a subclass
|
|
# that replaces it -- does not reach into the other.
|
|
#: Default tint for a favourite team's finished game.
|
|
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict(
|
|
_card.FAVORITE_RESULT_COLOR_DEFAULTS)
|
|
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR
|
|
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR
|
|
#: Bitmap fonts snap to their native pixel grid.
|
|
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID)
|
|
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES)
|
|
#: Accepted values for the other-games quality filter.
|
|
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
|
|
#: How long to stay quiet between ranking-coverage warnings.
|
|
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
|
|
|
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
"""Placeholder draw method - subclasses should override."""
|
|
# This base method will be simple, subclasses provide specifics
|
|
try:
|
|
img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
status = game.get("status_text", "N/A")
|
|
self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"],
|
|
element="status_text")
|
|
self.display_manager.image.paste(img, (0, 0))
|
|
# Don't call update_display here, let subclasses handle it after drawing
|
|
except Exception as e:
|
|
self.logger.error(
|
|
f"Error in base _draw_scorebug_layout: {e}", exc_info=True
|
|
)
|
|
|
|
@classmethod
|
|
def _crisp_size(cls, font_file, desired):
|
|
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
|
|
|
A face with no known grid is returned unchanged, so a user-supplied
|
|
font is never second-guessed. The class's own tables are passed, so a
|
|
host that declares extra faces keeps them.
|
|
"""
|
|
return _card.crisp_size(font_file, desired,
|
|
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
|
|
|
|
#: Absolute path of this plugin's directory, declared by the plugin
|
|
#: itself. The mixin cannot work it out -- see _plugin_dir.
|
|
_PLUGIN_DIR: ClassVar[Optional[str]] = None
|
|
|
|
def _plugin_dir(self) -> Optional[str]:
|
|
"""Directory of the plugin that owns this instance.
|
|
|
|
In sports.py these methods could just use ``__file__``. Here that is
|
|
src/common/, so the directory has to come from the plugin.
|
|
|
|
It is TOLD, not deduced. The first version walked the MRO for a class
|
|
whose module sits beside a config_schema.json. That works when a test
|
|
imports the plugin itself, and returns None under the real loader, for
|
|
a specific reason worth recording:
|
|
|
|
PluginLoader._namespace_plugin_modules renames every bare module a
|
|
plugin brought in (sports, game_renderer, ...) to
|
|
"_plg_<plugin_id>_<module>" and REMOVES the bare entry, so two
|
|
plugins owning a module of the same name cannot collide.
|
|
|
|
The class still reports ``__module__ == "sports"``, but
|
|
``sys.modules["sports"]`` no longer exists, so the walk finds no
|
|
__file__ and falls off the end. The failure was silent and expensive:
|
|
|
|
_plugin_dir() -> None
|
|
_schema_font_size() -> None for every element
|
|
-> a configured size equal to the schema default stops looking
|
|
like a default and is treated as a deliberate user choice
|
|
-> the snap to the font's pixel grid is skipped
|
|
-> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs
|
|
instead of 4px
|
|
|
|
On a 256x64 panel that made the odds, the team records and the date row
|
|
hard to read. It was found by a user counting pixels on the panel. No
|
|
gate here caught it: the tests imported plugins directly and the
|
|
safety harness loads them its own way, so neither reproduced the
|
|
loader's renaming.
|
|
|
|
The MRO walk stays as a fallback for hosts that declare no
|
|
_PLUGIN_DIR -- the plugins' own probe harnesses build classes with
|
|
``type()`` -- but it is no longer the primary answer.
|
|
"""
|
|
declared = getattr(self, "_PLUGIN_DIR", None)
|
|
if declared and os.path.isfile(os.path.join(declared, "config_schema.json")):
|
|
return declared
|
|
|
|
for cls in type(self).__mro__:
|
|
module = sys.modules.get(getattr(cls, "__module__", ""), None)
|
|
path = getattr(module, "__file__", None)
|
|
if not path:
|
|
continue
|
|
directory = os.path.dirname(os.path.abspath(path))
|
|
if os.path.isfile(os.path.join(directory, "config_schema.json")):
|
|
return directory
|
|
return None
|
|
|
|
def _schema_font_size(self, element_key):
|
|
"""The font_size this plugin's config_schema.json declares, or None."""
|
|
if not element_key:
|
|
return None
|
|
# Cached per class, not in sports_card's per-path cache: the display
|
|
# service rebuilds the class when it reloads a plugin, and that is
|
|
# what makes an edited schema take effect. Both caches parse through
|
|
# sports_card._read_schema_font_sizes.
|
|
cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
|
|
if cache is None:
|
|
cache = {}
|
|
try:
|
|
directory = self._plugin_dir()
|
|
if directory is None:
|
|
raise FileNotFoundError("no config_schema.json on the MRO")
|
|
cache = _card._read_schema_font_sizes(
|
|
os.path.join(directory, 'config_schema.json'))
|
|
except Exception as exc:
|
|
# Say so. An unreadable schema is not cosmetic: every element's
|
|
# configured size then stops matching "the schema default", is
|
|
# treated as a deliberate user choice, and skips the snap to the
|
|
# font's pixel grid -- which renders 4x6-font.ttf at 6 instead
|
|
# of 7, a 3px-wide glyph instead of 4px. That shipped once,
|
|
# silently, and was found by a user counting pixels on a photo
|
|
# of the panel.
|
|
#
|
|
# Logged, not raised: a missing schema must not stop a plugin
|
|
# rendering. The cache is built once per class, so this cannot
|
|
# repeat per frame.
|
|
logger.warning(
|
|
"%s: could not read config_schema.json (%s: %s); every font "
|
|
"size will be treated as user-chosen and will skip its pixel "
|
|
"grid snap. Font sizes may render a pixel narrow.",
|
|
type(self).__name__, type(exc).__name__, exc)
|
|
cache = {}
|
|
self.__class__._SCHEMA_FONT_SIZES = cache
|
|
return cache.get(element_key)
|
|
|
|
def _resolve_font_size(self, element_config, element_key, default_size, font_name):
|
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
|
|
|
A configured size counts as a real choice only when it differs from
|
|
the schema default. The web UI writes the whole schema default block
|
|
on every save, so "font_size == schema default" carries no intent and
|
|
would otherwise pin every install to an anti-aliased size forever.
|
|
"""
|
|
configured = (element_config or {}).get('font_size')
|
|
if configured is not None:
|
|
try:
|
|
configured = int(configured)
|
|
if configured != self._schema_font_size(element_key):
|
|
return configured
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return self._crisp_size(font_name, default_size)
|
|
|
|
def _card_option(self, key: str, default: Any = None) -> Any:
|
|
"""Read one key from the scroll_card config block."""
|
|
return _card.scroll_card_option(self.config, key, default)
|
|
|
|
def _switch_upcoming_center(self) -> str:
|
|
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
|
|
mode = str(self._card_option("switch_upcoming_center", "date_time")
|
|
or "date_time").lower()
|
|
if mode == "inherit":
|
|
mode = str(self._card_option("upcoming_center", "vs") or "vs").lower()
|
|
return mode if mode in ("vs", "date_time", "none") else "date_time"
|
|
|
|
def _vs_text(self) -> str:
|
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
|
return _card.vs_text(self.config)
|
|
|
|
def _switch_date_format(self) -> str:
|
|
"""Date style for the full-screen scorebug.
|
|
|
|
Its own key rather than the shared ``date_format`` because the two
|
|
displays disagree about the default: the scroll card renders "Sep 19"
|
|
while _extract_game_details_common emits "9/19", the "numeric" style,
|
|
and this scorebug has always drawn it. Reading the shared key here
|
|
would restyle every existing panel on update -- and "leave it alone
|
|
when unset" is not available, because the core merges schema defaults
|
|
into the config on every load, so the key is never actually unset.
|
|
"inherit" opts into the scroll and Vegas setting.
|
|
"""
|
|
fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower()
|
|
if fmt == "inherit":
|
|
fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower()
|
|
return fmt
|
|
|
|
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
|
|
"""Format an upcoming date per scroll_card.switch_date_format.
|
|
|
|
The formatting is sports_card's. What differs from the card's
|
|
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
|
see :meth:`_switch_date_format`) and the weekday, which comes from
|
|
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
|
"""
|
|
raw = str(date_text or "").strip()
|
|
if not raw:
|
|
return raw
|
|
return _card._format_date_as(self._switch_date_format(), raw,
|
|
lambda: self._weekday_for(game),
|
|
self._MONTH_ABBR)
|
|
|
|
def _weekday_for(self, game: Optional[Dict]) -> str:
|
|
"""Weekday abbreviation from the game's start time, or ''."""
|
|
if not game:
|
|
return ""
|
|
raw = game.get("start_time_utc") or game.get("start_time")
|
|
if not raw:
|
|
return ""
|
|
try:
|
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
|
str(raw).replace("Z", "+00:00"))
|
|
return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()]
|
|
except (ValueError, TypeError, OverflowError):
|
|
return ""
|
|
|
|
def _format_game_time(self, time_text: str) -> str:
|
|
"""Return the time as-is (12h) or converted to 24h."""
|
|
return _card.format_game_time(self.config, time_text)
|
|
|
|
def _scorebug_font(self, draw, text: str, width: int):
|
|
"""The face this scorebug draws its date and time in.
|
|
|
|
Always the "time" face, which is what this display has used for both
|
|
rows for as long as it has existed: changing switch_upcoming_center
|
|
moves the two lines around, it is not meant to restyle them, so the
|
|
type stays put while the placement changes.
|
|
|
|
The single exception is text that cannot fit the panel at all. Only
|
|
the "weekday" date can do that -- "Fri Sep 19" measures 80px in an
|
|
8px face, on a board 64px wide -- and the smaller "detail" face is a
|
|
better answer there than running off both edges. Every other date and
|
|
time this display can produce fits, so in practice the face never
|
|
changes; it is a floor, not a style rule.
|
|
"""
|
|
font = self.fonts["time"]
|
|
if not text:
|
|
return font
|
|
try:
|
|
if draw.textlength(text, font=font) + 2 <= width:
|
|
return font
|
|
except (TypeError, ValueError):
|
|
return font
|
|
return self.fonts.get("detail") or font
|
|
|
|
def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int,
|
|
game_date: str, game_time: str,
|
|
display_width: Optional[int] = None,
|
|
display_height: Optional[int] = None,
|
|
date_element: str = 'date',
|
|
time_element: str = 'time',
|
|
second_row_y_offset: bool = True) -> bool:
|
|
"""Draw the middle of the full-screen upcoming scorebug.
|
|
|
|
Returns True when the header above it ("Next Game", or the league
|
|
name) should still be drawn. In "vs" and "none" the date and time move
|
|
out of the middle and into the top and bottom slots, mirroring the
|
|
scroll card -- and the top slot is where the header used to be, so the
|
|
caller drops it.
|
|
|
|
``date_element``/``time_element``/``second_row_y_offset`` exist only so
|
|
the layout-offset keys stay exactly what each plugin's schema
|
|
advertises; this sport's defaults are the common case.
|
|
"""
|
|
width = self.display_width if display_width is None else display_width
|
|
height = self.display_height if display_height is None else display_height
|
|
mode = self._switch_upcoming_center()
|
|
date_text, time_text = self._upcoming_date_and_time_text(
|
|
game_date, game_time, game)
|
|
swapped = bool(self._card_option("swap_date_time", False))
|
|
|
|
if mode == "date_time":
|
|
# Historically the date sat at center_y - 7 with the time 9px
|
|
# under it, and the time's row was derived from the date's, so a
|
|
# date y_offset moved the pair. Both still hold; the slots only
|
|
# trade places when swap_date_time is set, and hiding one line
|
|
# leaves the other where it was rather than re-centering the stack.
|
|
slots = [(time_element, time_text), (date_element, date_text)] if swapped \
|
|
else [(date_element, date_text), (time_element, time_text)]
|
|
row_y = center_y - 7
|
|
for index, (element, text) in enumerate(slots):
|
|
if index:
|
|
row_y += 9
|
|
if second_row_y_offset:
|
|
row_y += self._get_layout_offset(element, 'y_offset')
|
|
else:
|
|
row_y += self._get_layout_offset(element, 'y_offset')
|
|
if not text:
|
|
continue
|
|
font = self._scorebug_font(draw, text, width)
|
|
text_width = draw.textlength(text, font=font)
|
|
text_x = ((width - text_width) // 2
|
|
+ self._get_layout_offset(element, 'x_offset'))
|
|
self._draw_text_with_outline(
|
|
draw, text, (text_x, row_y), font
|
|
)
|
|
return True
|
|
|
|
if mode == "vs":
|
|
vs_text = self._vs_text()
|
|
if vs_text:
|
|
vs_width = draw.textlength(vs_text, font=self.fonts["score"])
|
|
vs_x = ((width - vs_width) // 2
|
|
+ self._get_layout_offset('score', 'x_offset'))
|
|
vs_y = (center_y - 3
|
|
+ self._get_layout_offset('score', 'y_offset'))
|
|
vs_x = self._aligned_x('score_text', vs_width, width, vs_x)
|
|
self._draw_text_with_outline(
|
|
draw, vs_text, (vs_x, vs_y), self.fonts["score"],
|
|
element="score_text"
|
|
)
|
|
|
|
# "vs" and "none" both push the date and time out to the edges, time
|
|
# on top unless swap_date_time says otherwise -- the same order the
|
|
# scroll card uses.
|
|
if swapped:
|
|
top_element, top_text = date_element, date_text
|
|
bottom_element, bottom_text = time_element, time_text
|
|
else:
|
|
top_element, top_text = time_element, time_text
|
|
bottom_element, bottom_text = date_element, date_text
|
|
|
|
if top_text:
|
|
top_font = self._scorebug_font(draw, top_text, width)
|
|
top_width = draw.textlength(top_text, font=top_font)
|
|
top_x = ((width - top_width) // 2
|
|
+ self._get_layout_offset(top_element, 'x_offset'))
|
|
top_y = 1 + self._get_layout_offset(top_element, 'y_offset')
|
|
self._draw_text_with_outline(
|
|
draw, top_text, (top_x, top_y), top_font
|
|
)
|
|
if bottom_text:
|
|
bottom_font = self._scorebug_font(draw, bottom_text, width)
|
|
bottom_width = draw.textlength(bottom_text, font=bottom_font)
|
|
bottom_x = ((width - bottom_width) // 2
|
|
+ self._get_layout_offset(bottom_element, 'x_offset'))
|
|
# Measured, not a fixed offset: the detail font is 6px in most
|
|
# plugins and 10px in soccer and nrl, where a fixed -7 ran the
|
|
# date off the panel.
|
|
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
|
|
bottom_y = (max(0, height - ink_bottom - 1)
|
|
+ self._get_layout_offset(bottom_element, 'y_offset'))
|
|
self._draw_text_with_outline(
|
|
draw, bottom_text, (bottom_x, bottom_y), bottom_font
|
|
)
|
|
return False
|
|
|
|
@staticmethod
|
|
def _coerce_rgb(value, fallback):
|
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
|
return _card.coerce_rgb(value, fallback)
|
|
|
|
@staticmethod
|
|
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool:
|
|
"""Is the home/away side of this game a favorite team?
|
|
|
|
Both the abbreviation and the ESPN id are checked, because a couple of
|
|
leagues (NRL) match favorites by id where abbreviations collide.
|
|
"""
|
|
for key in (f"{side}_abbr", f"{side}_id"):
|
|
value = game.get(key)
|
|
if value is not None and str(value).strip().upper() in favorites:
|
|
return True
|
|
return False
|
|
|
|
def _favorite_result(self, game: Dict) -> Optional[str]:
|
|
"""Say how the favorite team did in a finished game.
|
|
|
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
|
to root for: no favorites configured, neither side is a favorite, or
|
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
|
flagging in red. Also None when the scores are not usable numbers.
|
|
"""
|
|
favorites = getattr(self, "favorite_teams", None) or []
|
|
favorites = {str(team).strip().upper() for team in favorites if str(team).strip()}
|
|
if not favorites:
|
|
return None
|
|
|
|
home_fav = self._side_is_favorite(game, "home", favorites)
|
|
away_fav = self._side_is_favorite(game, "away", favorites)
|
|
if home_fav == away_fav:
|
|
return None
|
|
|
|
try:
|
|
# int(float(...)) to match GameRenderer._side_score exactly -- the
|
|
# two paths must agree on what counts as a usable score.
|
|
home_score = int(float(str(game.get("home_score", "")).strip()))
|
|
away_score = int(float(str(game.get("away_score", "")).strip()))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
if home_score == away_score:
|
|
return "tie"
|
|
favorite_score, other_score = (
|
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
|
)
|
|
return "win" if favorite_score > other_score else "loss"
|
|
|
|
def _recent_score_color(self, game: Dict, default):
|
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
|
try:
|
|
settings = (self.config.get("customization") or {}).get(
|
|
"favorite_result_colors"
|
|
) or {}
|
|
if not settings.get("enabled", False):
|
|
return default
|
|
result = self._favorite_result(game)
|
|
if result is None:
|
|
return default
|
|
return self._coerce_rgb(
|
|
settings.get(f"{result}_color"),
|
|
self.FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
|
)
|
|
except Exception:
|
|
self.logger.debug(
|
|
"Could not resolve favorite result color", exc_info=True
|
|
)
|
|
return default
|
|
|
|
def _score_font_size(self) -> int:
|
|
"""Pixel size the score is currently drawn at."""
|
|
return getattr(self.fonts.get("score"), "size", 8) or 8
|
|
|
|
def _time_font_size(self) -> int:
|
|
"""Pixel size the clock/date face is currently drawn at."""
|
|
return getattr(self.fonts.get("time"), "size", 8) or 8
|
|
|
|
def _user_chose_size(self, element_key: str) -> bool:
|
|
"""True when customization.<element>.font_size is a real choice.
|
|
|
|
The web UI's save flow writes the whole schema default block into
|
|
config.json on every save, whether or not the user touched that
|
|
section, so a size merely being PRESENT carries no intent. Only one
|
|
that differs from the schema default does.
|
|
"""
|
|
element = (self.config.get('customization', {}) or {}).get(element_key) or {}
|
|
configured = element.get('font_size')
|
|
if configured is None:
|
|
return False
|
|
try:
|
|
return int(configured) != self._schema_font_size(element_key)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
def _grid_scaled_size(self, font):
|
|
"""(path, grid, size) for *font* regrown to this panel's height.
|
|
|
|
None when the panel is at or below the design height (nothing to do),
|
|
or when the face has no known pixel grid -- a user-supplied font is
|
|
never second-guessed, because we do not know what it renders crisply
|
|
at.
|
|
"""
|
|
path = getattr(font, 'path', None)
|
|
base = getattr(font, 'size', None)
|
|
if not base or not isinstance(path, str):
|
|
return None
|
|
face = os.path.basename(path)
|
|
grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face))
|
|
if not grid:
|
|
return None
|
|
scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32)
|
|
if scale <= 1.0:
|
|
return None
|
|
return path, grid, max(int(base), int(self._crisp_size(face, base * scale)))
|
|
|
|
def _scale_headline_fonts(self, fonts):
|
|
"""Grow the score with the panel, and hold the clock/date below it.
|
|
|
|
The score is the one number the card exists to show, and it was the
|
|
only element not sized from the panel. Worse, it was not even bigger
|
|
than its neighbours: PressStart2P renders crisply on an 8px grid, so
|
|
the 10px default snapped to 8 -- the same 8 the period/clock above it
|
|
and the game date below it are drawn at. Three lines of identical
|
|
type, none of them the headline, which is what makes the score read as
|
|
lower priority than the time and the date rather than the point of the
|
|
card.
|
|
|
|
So the score is sized from display_height and snapped to its face's
|
|
pixel grid (off the grid FreeType anti-aliases the strokes, and on an
|
|
LED matrix a part-lit pixel is a dim lamp rather than a soft edge),
|
|
then stepped back down that grid until it fits its share of the width.
|
|
The clock/date face is regrown the same way but held at least one grid
|
|
step below the score, so the ranking between them is visible rather
|
|
than implied.
|
|
|
|
A 32-tall panel scales by exactly 1.0 and is left byte-identical; a
|
|
size the user set explicitly is never overridden.
|
|
"""
|
|
self._score_grew = False
|
|
if not self._DRAWS_SCORE:
|
|
# No score on this screen, so none of the sizing below is for it.
|
|
return fonts
|
|
try:
|
|
scaled = None if self._user_chose_size('score_text') else \
|
|
self._grid_scaled_size(fonts.get('score'))
|
|
if scaled is not None:
|
|
path, grid, size = scaled
|
|
base = getattr(fonts['score'], 'size', size) or size
|
|
size = min(size, base * self._SCORE_MAX_GROWTH)
|
|
probe = ImageDraw.Draw(Image.new('RGB', (4, 4)))
|
|
budget = self.display_width * self._SCORE_GROWTH_BUDGET
|
|
# Measured from a fixed five-character score rather than the
|
|
# live one, so the card does not resize when a side passes 9.
|
|
while size > grid:
|
|
if probe.textlength(
|
|
self._SCORE_PROBE_TEXT,
|
|
font=load_truetype(path, size)) <= budget:
|
|
break
|
|
size -= grid
|
|
if size != getattr(fonts['score'], 'size', size):
|
|
fonts['score'] = load_truetype(path, size)
|
|
self._score_grew = True
|
|
|
|
if not self._score_grew and not self._user_chose_size('score_text') \
|
|
and self.display_height > self._FONT_DESIGN_HEIGHT:
|
|
# PressStart2P could not grow inside the budget -- its next crisp
|
|
# size is simply too wide for this panel. A narrower face still
|
|
# can: 4x6-font at 14px is nearly as tall as PressStart2P at 16
|
|
# and about half as wide. This matters beyond the score itself,
|
|
# because a card whose score never grows never reserves the
|
|
# centre either, so its logos stay at the uncapped 1.5x and are
|
|
# drawn straight over the score -- which is what a three-digit
|
|
# basketball score does on a 128x64 board.
|
|
probe = ImageDraw.Draw(Image.new('RGB', (4, 4)))
|
|
budget = self.display_width * self._SCORE_GROWTH_BUDGET
|
|
current = getattr(fonts.get('score'), 'size', 0) or 0
|
|
for _name, _size in self._NARROW_SCORE_RUNGS:
|
|
if _size <= current:
|
|
continue
|
|
_path = _resolve_font_path(f"assets/fonts/{_name}")
|
|
_candidate = load_truetype(_path, _size)
|
|
if probe.textlength(self._SCORE_PROBE_TEXT,
|
|
font=_candidate) <= budget:
|
|
fonts['score'] = _candidate
|
|
self._score_grew = True
|
|
break
|
|
|
|
scaled = None if self._user_chose_size('period_text') else \
|
|
self._grid_scaled_size(fonts.get('time'))
|
|
if scaled is not None:
|
|
path, grid, size = scaled
|
|
ceiling = getattr(fonts.get('score'), 'size', 0) or 0
|
|
if ceiling and size >= ceiling:
|
|
size = max(grid, ceiling - grid)
|
|
if size != getattr(fonts['time'], 'size', size):
|
|
fonts['time'] = load_truetype(path, size)
|
|
except Exception:
|
|
self.logger.debug("Headline font scaling skipped", exc_info=True)
|
|
return fonts
|
|
|
|
def _get_layout_offset(self, element: str, axis: str,
|
|
default: int = 0) -> int:
|
|
"""X/Y nudge for one element, from ``customization.layout``.
|
|
|
|
Promoted here so every scoreboard reads offsets the same way the
|
|
scroll card does. Each plugin still carries its own copy in its
|
|
bundled sports.py, which wins by MRO until that copy is deleted --
|
|
deleting it is what buys the alias handling (a plugin asking for
|
|
``score_text`` finds the ``score`` its users configured) and the
|
|
per-mode overrides, since this resolves through SKIN_MODE.
|
|
"""
|
|
from src.element_style import layout_offset
|
|
return layout_offset(self.config, element, axis, default,
|
|
getattr(self, "SKIN_MODE", None))
|
|
|
|
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
|
|
"""Per-element text colour from customization.<element>.text_color.
|
|
|
|
Mode-aware through SKIN_MODE, so Live and Recent instances of the
|
|
same scoreboard resolve their own colours without any call site
|
|
passing a mode.
|
|
"""
|
|
from src.element_style import element_color as _shared
|
|
return _shared(self.config, element, default,
|
|
getattr(self, "SKIN_MODE", None))
|
|
|
|
def _element_visible(self, element: str, default: bool = True) -> bool:
|
|
"""Whether ``customization.<element>.visible`` allows this draw.
|
|
|
|
Mode-aware like the colour read, so a user can hide the records on the
|
|
recent card and keep them on the upcoming one.
|
|
"""
|
|
from src.element_style import element_visible
|
|
return element_visible(self.config, element, default,
|
|
getattr(self, "SKIN_MODE", None))
|
|
|
|
def _element_align(self, element: str, default: Optional[str] = None):
|
|
"""``customization.<element>.align``: 'left', 'center' or 'right'."""
|
|
from src.element_style import element_align
|
|
return element_align(self.config, element, default,
|
|
getattr(self, "SKIN_MODE", None))
|
|
|
|
def _element_scale(self, element: str, default: float = 1.0) -> float:
|
|
"""``customization.layout.<element>.scale`` -- logos, mostly."""
|
|
from src.element_style import element_scale
|
|
return element_scale(self.config, element, default,
|
|
getattr(self, "SKIN_MODE", None))
|
|
|
|
def _aligned_x(self, element: str, text_width: float, container_width: int,
|
|
centered_x: float) -> float:
|
|
"""Where a run of text starts, honouring ``align``.
|
|
|
|
Unset means "leave it exactly where it was", so this returns the
|
|
caller's own x rather than re-deriving a centre: these draws have
|
|
accumulated per-sport nudges and a centre computed here would not be
|
|
the same pixel.
|
|
"""
|
|
align = self._element_align(element)
|
|
if not align:
|
|
return centered_x
|
|
if align == 'left':
|
|
return 0
|
|
if align == 'right':
|
|
return max(0, container_width - text_width)
|
|
return centered_x
|
|
|
|
def _unshare_element_fonts(self, fonts):
|
|
"""Give each colourable element its own face object.
|
|
|
|
The colour a draw gets is resolved from the face it was handed, and
|
|
several of these loaders legitimately hand one object to more than one
|
|
element -- a size resolver that lands two elements on the same face, a
|
|
fallback that fills every key from one default, football's narrowing
|
|
step that deliberately shrinks the clock along with the score. Sharing
|
|
the object makes the element ambiguous and the colour unresolvable.
|
|
|
|
Re-instantiating from the same path and size gives a distinct object
|
|
with identical metrics, so nothing about the rendering changes; only
|
|
the ability to tell two elements apart does. Faces that cannot be
|
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
|
path) are left shared, and their draws stay white as before.
|
|
|
|
The body is sports_card's; this class's own element map is passed, so
|
|
the keys considered are the ones this class colours by.
|
|
"""
|
|
return _card.unshare_element_fonts(self.logger, fonts,
|
|
self._ELEMENT_FOR_FONT)
|
|
|
|
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
|
"""Colour for whichever element owns this face.
|
|
|
|
The fallback for draw sites that were only ever handed a font. Prefer
|
|
``element=`` on :meth:`_draw_text_with_outline`, which needs none of
|
|
this. Shared with the scroll card's copy so the narrowing rule that
|
|
rescues bitmap-font colours lives in one place; the element vocabulary
|
|
stays this class's own, because its map says ``team_text`` where
|
|
sports_card's says ``team_name``.
|
|
"""
|
|
from src.common.sports_card import resolve_font_color
|
|
return resolve_font_color(
|
|
getattr(self, "config", None), getattr(self, "fonts", None), font,
|
|
default, self._ELEMENT_FOR_FONT, getattr(self, "SKIN_MODE", None))
|
|
|
|
def _draw_text_with_outline(
|
|
self, draw, text, position, font, fill=None, outline_color=(0, 0, 0),
|
|
element=None
|
|
):
|
|
"""Draw text with a black outline for better readability.
|
|
|
|
Pass ``element`` (``"score_text"``, ``"status_text"``, ...) wherever the
|
|
caller knows what it is drawing: the colour is then read by name, which
|
|
is exact. Without it the colour has to be inferred from the identity of
|
|
the font object, which cannot tell two elements apart when they share a
|
|
face -- the case every bitmap font is in, because a ``freetype.Face``
|
|
cannot be re-instantiated.
|
|
"""
|
|
# Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get
|
|
# anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying
|
|
# glyphs. 1-bit mode keeps strokes crisp.
|
|
# Defaults to the configured colour for whichever element owns
|
|
# this face rather than to white, so customization.<element>.text_color
|
|
# reaches every draw. The schema has offered those pickers all along
|
|
# and they only ever changed the font. An explicit fill still wins:
|
|
# the odds colours and the favourite-result score tint mean something
|
|
# the palette does not.
|
|
if element is not None:
|
|
# Named, so both questions can be answered exactly: whether this
|
|
# element is meant to be on screen at all, and what colour it is.
|
|
if not self._element_visible(element):
|
|
return
|
|
if fill is None:
|
|
fill = self._element_color(element)
|
|
elif fill is None:
|
|
fill = self._font_color(font)
|
|
draw.fontmode = "1"
|
|
x, y = position
|
|
for dx, dy in [
|
|
(-1, -1),
|
|
(-1, 0),
|
|
(-1, 1),
|
|
(0, -1),
|
|
(0, 1),
|
|
(1, -1),
|
|
(1, 0),
|
|
(1, 1),
|
|
]:
|
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
|
draw.text((x, y), text, font=font, fill=fill)
|
|
|
|
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
|
"""True at most once per ``cooldown`` seconds, for rate-limiting a
|
|
warning. The cooldown is shared by every warning on this manager:
|
|
``warning_type`` is part of the signature scoreboards inherit, but
|
|
does not give each type its own cooldown."""
|
|
current_time = time.time()
|
|
if current_time - self._last_warning_time > cooldown:
|
|
self._last_warning_time = current_time
|
|
return True
|
|
return False
|
|
|
|
def _get_weeks_data(self) -> Optional[Dict]:
|
|
"""
|
|
Get partial data for immediate display while background fetch is in progress.
|
|
This fetches current/recent games only for quick response.
|
|
"""
|
|
try:
|
|
# Fetch current week and next few days for immediate display
|
|
now = datetime.now(pytz.utc)
|
|
start_date = now - timedelta(days=self.schedule_lookback_days)
|
|
end_date = now + timedelta(days=self.schedule_lookahead_days)
|
|
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
|
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
|
|
data = fetch_espn_scoreboard(
|
|
self.session,
|
|
url,
|
|
params={"dates": date_str, "limit": ESPN_MAX_LIMIT},
|
|
headers=self.headers,
|
|
timeout=10,
|
|
logger=self.logger,
|
|
)
|
|
immediate_events = data.get("events", [])
|
|
|
|
if immediate_events:
|
|
self.logger.info(f"Fetched {len(immediate_events)} events {date_str}")
|
|
return {"events": immediate_events}
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
self.logger.warning(
|
|
f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}"
|
|
)
|
|
return None
|
|
|
|
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
|
|
pass
|
|
|
|
def cleanup(self):
|
|
"""Clean up resources when plugin is unloaded."""
|
|
# Close HTTP session
|
|
if hasattr(self, 'session') and self.session:
|
|
try:
|
|
self.session.close()
|
|
except Exception as e:
|
|
self.logger.warning(f"Error closing session: {e}")
|
|
|
|
# Clear caches
|
|
if hasattr(self, '_logo_cache'):
|
|
self._logo_cache.clear()
|
|
|
|
self.logger.info(f"{self.__class__.__name__} cleanup completed")
|
|
|
|
def _game_divisions(self, game: Dict) -> Optional[set]:
|
|
"""Divisions of BOTH sides, or None when they cannot be told.
|
|
|
|
Both sides are collected, but the caller only needs ONE of them to sit
|
|
in a checked division. Requiring every participant read as "FBS games
|
|
only" and removed a ranked side hosting an FCS school -- which is still
|
|
a game involving a team the viewer checked the box for, and on a real
|
|
Week 2 slate it silently dropped five of the twenty ranked matchups.
|
|
What the checkbox is for is keeping FCS-versus-FCS out of a board
|
|
configured for FBS, and that still holds: a game with no checked
|
|
division on either side is dropped.
|
|
"""
|
|
divisions = self._load_division_team_ids()
|
|
if not any(divisions.values()):
|
|
return None
|
|
try:
|
|
ids = [int(game.get("home_id")), int(game.get("away_id"))]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
present = set()
|
|
for team_id in ids:
|
|
for name in ("fbs", "fcs"):
|
|
if team_id in divisions.get(name, set()):
|
|
present.add(name)
|
|
break
|
|
else:
|
|
present.add("other")
|
|
return present
|
|
|
|
def _league_has_rankings(self) -> bool:
|
|
"""Only college leagues publish a poll; everyone else 404s.
|
|
|
|
This gate matters more than it looks. _fetch_team_rankings only
|
|
short-circuits when the cache is non-empty, so a failed fetch leaves it
|
|
empty and the next update tries again -- at a 30s interval that is
|
|
~2,900 pointless requests a day, per league, all of them 404s.
|
|
"""
|
|
league = (self.league or "").lower()
|
|
return "college" in league or "ncaa" in league
|
|
|
|
@staticmethod
|
|
def _normalise_divisions(raw) -> List[str]:
|
|
"""Division names from config, in the shape the filter expects.
|
|
|
|
A hand-edited config can hold "fbs" where the schema says ["fbs"], and
|
|
list("fbs") is ['f', 'b', 's'] -- three names that match no division, so
|
|
every non-favourite game is rejected by a setting the user believes says
|
|
the opposite. An empty list is left empty: that means "no division
|
|
filter" and is a legitimate choice, not a mistake to correct.
|
|
"""
|
|
if isinstance(raw, str):
|
|
raw = [raw]
|
|
try:
|
|
items = list(raw or [])
|
|
except TypeError:
|
|
return []
|
|
return [str(d).strip().lower() for d in items if str(d).strip()]
|
|
|
|
def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]:
|
|
"""Each favourite team's next game before any team's second one.
|
|
|
|
Taking the soonest N favourite games spends the slots on whoever plays
|
|
most often. Walked across a real season with two favourites and a limit
|
|
of 2, nine days of it showed Auburn twice and Georgia not at all --
|
|
Auburn played either side of a Georgia bye, so both slots went to
|
|
Auburn. The other-games pool already refuses to do this; favourites
|
|
were still doing it.
|
|
|
|
Depth is kept where there is room: one favourite with three slots still
|
|
gets its next three games, because the round-robin only comes back for
|
|
a team's second game once every team has had a first.
|
|
|
|
A game between two favourites is picked once and counts for both.
|
|
"""
|
|
if limit <= 0 or not games:
|
|
return []
|
|
wanted = [t for t in (self.favorite_teams or []) if t]
|
|
if len(wanted) < 2:
|
|
return games[:limit] # nothing to share the slots between
|
|
|
|
# Which side of a game belongs to which favourite is a per-lineage
|
|
# question: NRL matches on ESPN team IDs because its abbreviations are
|
|
# not unique ("NEW" is both Newcastle and New Zealand), while the rest
|
|
# match on abbreviation. Ask for the lineage's own matcher rather than
|
|
# assuming, or this silently groups nothing and every slot goes empty.
|
|
matcher = getattr(self, "_team_in", None)
|
|
if not callable(matcher):
|
|
matcher = None # an is-None test narrows for static analysis
|
|
if matcher is None:
|
|
def belongs(game, team):
|
|
return team in (game.get("home_abbr"), game.get("away_abbr"))
|
|
else:
|
|
def belongs(game, team):
|
|
return bool(matcher(game.get("home_id"), [team])
|
|
or matcher(game.get("away_id"), [team]))
|
|
|
|
queues = {team: [] for team in wanted}
|
|
for game in games: # already in kickoff order
|
|
for team in wanted:
|
|
if belongs(game, team):
|
|
queues[team].append(game)
|
|
|
|
picked, taken = [], set()
|
|
while len(picked) < limit:
|
|
progressed = False
|
|
for team in wanted:
|
|
queue = queues[team]
|
|
while queue and queue[0].get("id") in taken:
|
|
queue.pop(0)
|
|
if queue and len(picked) < limit:
|
|
game = queue.pop(0)
|
|
taken.add(game.get("id"))
|
|
picked.append(game)
|
|
progressed = True
|
|
if not progressed:
|
|
break # every queue is empty
|
|
return picked
|
|
|
|
def _normalise_quality(self, raw) -> str:
|
|
"""other_games_min_quality, as one of the values the code implements.
|
|
|
|
An unusable value used to fall through every branch of
|
|
_passes_other_filters and silently mean "any" -- a quality bar the
|
|
board believes it has and does not.
|
|
"""
|
|
value = str(raw or "").strip().lower()
|
|
if value in self._QUALITY_CHOICES:
|
|
return value
|
|
if value == "broadcast":
|
|
# Retired in football-scoreboard 3.0.0 and now here. Measured
|
|
# against a real Week 1 and Week 2 college slate it passed 174 of
|
|
# 175 games: ESPN publishes a broadcaster for nearly everything
|
|
# now, ESPN+ included, so the tier read as a quality bar and
|
|
# behaved as "any". Boards holding it get the bar they thought
|
|
# they were getting.
|
|
self.logger.warning(
|
|
"%s: other_games_min_quality 'broadcast' has been retired -- "
|
|
"it let through nearly every game -- using 'ranked'. Change "
|
|
"the setting to clear this.", getattr(self, "sport_key", "?"),
|
|
)
|
|
return "ranked"
|
|
self.logger.warning(
|
|
"%s: ignoring unusable other_games_min_quality=%r, using 'ranked'",
|
|
getattr(self, "sport_key", "?"), raw,
|
|
)
|
|
return "ranked"
|
|
|
|
def _check_ranking_coverage(self, games: List[Dict]) -> None:
|
|
"""Say so when a loaded poll matches nothing on the schedule.
|
|
|
|
The table is keyed by the abbreviation the RANKINGS endpoint returns and
|
|
matched against the one the SCOREBOARD endpoint returns. Nothing
|
|
guarantees the two agree, and if they ever stop agreeing the filter
|
|
quietly removes every non-favourite game -- no exception, no log line,
|
|
just a shorter board. That is the same shape as the bug where rankings
|
|
were never loading at all, which survived until someone went looking.
|
|
|
|
Throttled to once an hour: selection runs on every update.
|
|
"""
|
|
if self.other_games_min_quality != "ranked":
|
|
return
|
|
rankings = getattr(self, "_team_rankings_cache", None) or {}
|
|
if not rankings or not games:
|
|
return
|
|
if any(self._is_ranked_game(g) for g in games):
|
|
return
|
|
now = time.monotonic()
|
|
# Zero means never logged, not "logged at the epoch". monotonic() counts
|
|
# from an arbitrary origin -- on a freshly booted board it is a few
|
|
# hundred seconds -- so comparing against 0 swallowed the first warning
|
|
# for the first hour of uptime, which is exactly when a misconfigured
|
|
# board is being watched. CI caught this; a machine with days of uptime
|
|
# cannot.
|
|
if (self._ranking_coverage_logged_at
|
|
and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS):
|
|
return
|
|
self._ranking_coverage_logged_at = now
|
|
self.logger.warning(
|
|
"%s: %d ranked teams loaded, but none of the %d other games match "
|
|
"one -- the quality filter is removing every non-favourite game. "
|
|
"Ranked abbreviations look like: %s",
|
|
self.league, len(rankings), len(games),
|
|
", ".join(sorted(rankings)[:8]),
|
|
)
|
|
|
|
def _favorites_first(
|
|
self,
|
|
processed_games: List[Dict],
|
|
favorite_limit: int,
|
|
other_limit: int,
|
|
newest_first: bool = False,
|
|
) -> List[Dict]:
|
|
"""Favourite games first, then a bounded number of everything else.
|
|
|
|
This is the middle setting the plugin was missing. `show_favorite_teams_only`
|
|
used to be the whole story: on, and you saw nothing but your teams; off,
|
|
and your teams were ignored entirely -- the selection just took the next
|
|
N games league-wide, so a UGA fan with 946 upcoming college games in the
|
|
window saw UGA about as often as chance allowed.
|
|
|
|
Both counts are TOTALS here, not per-team. In favourites-only mode
|
|
`upcoming_games_to_show` is a per-team budget, which is reasonable when
|
|
the list is your own teams; applied to a dynamic group it is not. With
|
|
AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct
|
|
cards before a single non-favourite is added. A total keeps the rotation
|
|
the length the user asked for.
|
|
"""
|
|
if newest_first:
|
|
def key(g):
|
|
return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
|
|
ordered = sorted(processed_games, key=key, reverse=True)
|
|
else:
|
|
def key(g):
|
|
return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
|
|
ordered = sorted(processed_games, key=key)
|
|
|
|
favorites, others, unfiltered = [], [], []
|
|
for game in ordered:
|
|
if self._is_favorite_game(game):
|
|
favorites.append(game) # never filtered: your team is your team
|
|
continue
|
|
unfiltered.append(game)
|
|
if self._passes_other_filters(game):
|
|
others.append(game)
|
|
self._check_ranking_coverage(unfiltered)
|
|
|
|
self._selection_pools = {
|
|
"favorites": favorites,
|
|
"others": self._by_importance(others, newest_first),
|
|
"unfiltered": self._by_importance(unfiltered, newest_first),
|
|
"favorite_limit": favorite_limit,
|
|
"other_limit": other_limit,
|
|
"newest_first": newest_first,
|
|
}
|
|
return self._compose_selection()
|
|
|
|
def _compose_selection(self) -> List[Dict]:
|
|
"""Favourites plus the current slice of others, in schedule order.
|
|
|
|
Split out of _favorites_first so the slice can be re-cut between
|
|
fetches. The pools are settled -- which games exist, and which of them
|
|
are worth a slot -- while WHICH of the others is on screen is a display
|
|
decision, and gating it on the fetch made the rotation interval a lie:
|
|
update() returns early until upcoming_update_interval has passed, so a
|
|
four-minute rotation actually stepped fifteen windows once an hour.
|
|
Same lesson as _advance_live_game_if_due further down this file.
|
|
"""
|
|
pools = self._selection_pools
|
|
favorites, others = pools["favorites"], pools["others"]
|
|
favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"]
|
|
newest_first = pools["newest_first"]
|
|
if newest_first:
|
|
def key(g):
|
|
return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
|
|
else:
|
|
def key(g):
|
|
return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
|
|
|
|
selected = self._round_robin_favorites(favorites, max(0, favorite_limit))
|
|
selected.extend(self._other_games_window(others, max(0, other_limit)))
|
|
if not selected and other_limit > 0:
|
|
# Nothing survived at all: your teams are not playing inside the
|
|
# schedule window AND the filters removed every other game. Each
|
|
# check fails open on missing data, but a filter working exactly as
|
|
# asked can still match nothing on a given day, and with no
|
|
# favourite game left there is nothing to carry the mode -- an empty
|
|
# list is a blank panel, not a short one. Same whole-list fallback
|
|
# `_filtered_or_all` makes for a board with no favourites at all.
|
|
# `other_limit` of 0 is an explicit "favourites only", so that one
|
|
# is left to go quiet as asked.
|
|
selected = self._other_games_window(pools["unfiltered"], max(0, other_limit))
|
|
# Re-sort so the card order still reads as a schedule. Selection decides
|
|
# WHICH games; it should not reorder them into favourites-then-others,
|
|
# which would show next week's UGA game before tonight's.
|
|
selected.sort(key=key, reverse=newest_first)
|
|
return selected
|
|
|
|
|
|
class SportsLiveSharedMixin:
|
|
"""The ``SportsLive`` bodies identical in all eight scoreboards."""
|
|
|
|
def _detect_stale_games(self, games: List[Dict]) -> None:
|
|
"""Remove games that appear stale or haven't updated."""
|
|
current_time = time.time()
|
|
|
|
for game in games[:]: # Copy list to iterate safely
|
|
game_id = game.get("id")
|
|
if not game_id:
|
|
continue
|
|
|
|
# Check if game data is stale
|
|
timestamps = self.game_update_timestamps.get(game_id, {})
|
|
last_seen = timestamps.get("last_seen", 0)
|
|
|
|
if last_seen > 0 and current_time - last_seen > self.stale_game_timeout:
|
|
self.logger.warning(
|
|
f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} "
|
|
f"(last seen {int(current_time - last_seen)}s ago)"
|
|
)
|
|
games.remove(game)
|
|
if game_id in self.game_update_timestamps:
|
|
del self.game_update_timestamps[game_id]
|
|
continue
|
|
|
|
# Also check if game appears to be over
|
|
if self._is_game_really_over(game):
|
|
self.logger.debug(
|
|
f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} "
|
|
f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})"
|
|
)
|
|
games.remove(game)
|
|
if game_id in self.game_update_timestamps:
|
|
del self.game_update_timestamps[game_id]
|
|
|
|
def _idle_live_interval(self) -> int:
|
|
"""How long to wait before looking for live games again, when there are none.
|
|
|
|
Escalates the longer nothing turns up, and any live game resets it, so
|
|
an in-season gap between games costs at most one escalated wait while
|
|
an out-of-season league stops polling on a live cadence entirely.
|
|
|
|
Capped rather than unbounded: the cost of backing off is how late the
|
|
first game after a quiet spell is noticed, and past the cap the saving
|
|
stops being worth that.
|
|
|
|
The escalation is then clamped by the next kickoff the league already
|
|
knows about -- see _clamp_to_scheduled_start. Without that clamp the cap
|
|
*is* the miss: a league idle overnight reaches the ceiling, and the
|
|
first game of the next day is not noticed for up to that long.
|
|
"""
|
|
streak = getattr(self, "_empty_live_streak", 0)
|
|
base = self.no_data_interval
|
|
ceiling = getattr(self, "live_idle_max_interval",
|
|
_DEFAULT_LIVE_IDLE_MAX_SECONDS)
|
|
# The ceiling bounds the un-escalated interval too. The two settings are
|
|
# independent integers with no cross-validation, so base > ceiling is a
|
|
# reachable config -- and returning base unclamped there made the wait
|
|
# *shrink* as the streak grew (3600s at streak 0, 900s at streak 24),
|
|
# the opposite of what the setting named "maximum" promises.
|
|
if streak >= _IDLE_LONG_STREAK:
|
|
interval = min(int(base * _IDLE_LONG_FACTOR), ceiling)
|
|
elif streak >= _IDLE_SHORT_STREAK:
|
|
interval = min(int(base * _IDLE_SHORT_FACTOR), ceiling)
|
|
else:
|
|
interval = min(base, ceiling)
|
|
return self._clamp_to_scheduled_start(interval)
|
|
|
|
def _clamp_to_scheduled_start(self, interval: int) -> int:
|
|
"""Shorten an idle wait that would sleep through a known kickoff.
|
|
|
|
The back-off counts consecutive empty looks and nothing else, so it
|
|
cannot tell an out-of-season league from an in-season one a few hours
|
|
before kickoff. Both reach the ceiling, and the ceiling then becomes the
|
|
blind spot: measured on two rigs on 2026-09-19, gaps of up to 928s
|
|
between looks, 10 of them at or above 900s. A game starting inside such
|
|
a gap is not noticed until it ends -- which is the "it doesn't pick up
|
|
new live games until I restart it" report, restarting being the one
|
|
thing that forces an immediate look.
|
|
|
|
The fix costs no extra request: the live fetch already downloads the
|
|
whole day's scoreboard, upcoming games included, and
|
|
_note_scheduled_start_candidate keeps the earliest start still ahead of
|
|
us out of exactly that payload.
|
|
|
|
Two cases, either side of the kickoff:
|
|
|
|
* before it -- wait at most until it starts, never past it;
|
|
* just after it -- hold the live cadence for _KICKOFF_GRACE_SECONDS,
|
|
because a provider that has not yet flipped the status would
|
|
otherwise look like another empty check and escalate the back-off
|
|
again, right when the game is actually starting.
|
|
"""
|
|
start = getattr(self, "_next_scheduled_start_ts", None)
|
|
if not start:
|
|
return interval
|
|
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
|
now = time.time()
|
|
if now < start:
|
|
return max(live, min(interval, int(start - now)))
|
|
if now - start <= _KICKOFF_GRACE_SECONDS:
|
|
return live
|
|
return interval
|
|
|
|
def _note_scheduled_start_candidate(self, details) -> None:
|
|
"""Offer a game from the current look as the next kickoff to wake for.
|
|
|
|
Called for every event the live fetch returns, live or not, so the
|
|
earliest start still ahead of us falls out of the payload the manager
|
|
already has. Self-correcting: a stored start that has passed is
|
|
replaced by the next one offered, so a postponed game cannot pin the
|
|
cadence to a kickoff that never happens.
|
|
"""
|
|
if not isinstance(details, dict):
|
|
return
|
|
if details.get("is_live") or details.get("is_halftime"):
|
|
return
|
|
start = details.get("start_time_utc")
|
|
timestamp = getattr(start, "timestamp", None)
|
|
if timestamp is None:
|
|
return
|
|
try:
|
|
candidate = float(timestamp())
|
|
except (TypeError, ValueError, OSError, OverflowError):
|
|
return
|
|
now = time.time()
|
|
if candidate <= now:
|
|
return
|
|
current = getattr(self, "_next_scheduled_start_ts", None)
|
|
# A kickoff that has only just passed is *kept*, not replaced by the
|
|
# next one on the card. Replacing it immediately is what made the grace
|
|
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
|
# round, the stored start jumped to the 16:05 games, `now < start` went
|
|
# true again, and the back-off returned to its ceiling -- at exactly the
|
|
# moment the games were starting. Observed live on 2026-09-20: the rig
|
|
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
|
# the status yet, and then went quiet for the next quarter of an hour,
|
|
# which is the behaviour this whole clamp exists to prevent.
|
|
if (current is None
|
|
or current <= now - _KICKOFF_GRACE_SECONDS
|
|
or candidate < current):
|
|
self._next_scheduled_start_ts = candidate
|
|
|
|
def _note_live_fetch(self, found_live: bool) -> None:
|
|
"""Record whether a look for live games found any."""
|
|
if found_live:
|
|
if getattr(self, "_empty_live_streak", 0):
|
|
self.logger.info(
|
|
"Live games found after %d empty check(s); back to the "
|
|
"live update interval", self._empty_live_streak)
|
|
self._empty_live_streak = 0
|
|
else:
|
|
self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1
|
|
|
|
|
|
class SportsRecentSharedMixin:
|
|
"""The ``SportsRecent`` bodies identical in all eight scoreboards."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: Dict[str, Any],
|
|
display_manager,
|
|
cache_manager,
|
|
logger: logging.Logger,
|
|
sport_key: str,
|
|
):
|
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
self.games_list = [] # Filtered list for display (favorite teams)
|
|
self.current_game_index = 0
|
|
self.last_update = 0
|
|
self.update_interval = self.mode_config.get(
|
|
"recent_update_interval", 3600
|
|
) # Check for recent games every hour
|
|
self.last_game_switch = 0
|
|
self.game_display_duration = self.mode_config.get("recent_game_duration", 15)
|
|
self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00
|
|
|
|
def _get_zero_clock_duration(self, game_id: str) -> float:
|
|
"""Track how long a game has been at 0:00 clock."""
|
|
current_time = time.time()
|
|
if game_id not in self._zero_clock_timestamps:
|
|
self._zero_clock_timestamps[game_id] = current_time
|
|
return 0.0
|
|
return current_time - self._zero_clock_timestamps[game_id]
|
|
|
|
def _clear_zero_clock_tracking(self, game_id: str) -> None:
|
|
"""Clear tracking when game clock moves away from 0:00 or game ends."""
|
|
if game_id in self._zero_clock_timestamps:
|
|
del self._zero_clock_timestamps[game_id]
|
|
|