mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35: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>
513 lines
21 KiB
Python
513 lines
21 KiB
Python
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
|
|
|
The eight scoreboards each carried byte-identical copies of the functions
|
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
|
made eight times, and a new scoreboard started by copying them a ninth.
|
|
|
|
Everything here is a **free function taking explicit arguments**, not a base
|
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
|
method and delegates the body, so the call sites and the override points are
|
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
|
rather than attributes -- the helper never reaches back into the caller.
|
|
|
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
|
(`not desired`) instead of football's: they agree on every real input, and
|
|
the extra guard only stops a None size raising TypeError.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Optional, Tuple
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from src.common.font_layout import ( # noqa: F401 - re-exported, see below
|
|
FONT_NAME_ALIASES, FONT_PIXEL_GRID, crisp_size,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
|
"side_is_favorite", "side_score", "favorite_result",
|
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
|
"resolve_font_size", "unshare_element_fonts",
|
|
]
|
|
|
|
#: Which customization element owns each font key, for colour resolution.
|
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
|
"score": "score_text",
|
|
"time": "period_text",
|
|
"team": "team_name",
|
|
"status": "status_text",
|
|
"detail": "detail_text",
|
|
"rank": "rank_text",
|
|
}
|
|
|
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
|
"win": (0, 255, 0),
|
|
"loss": (255, 0, 0),
|
|
"tie": (255, 200, 0),
|
|
}
|
|
|
|
# Re-exported rather than defined: the grid tables and the snapping rule are
|
|
# properties of the font files, which the display core needs too (it loads the
|
|
# same two faces in DisplayManager._load_fonts). They live in
|
|
# src/common/font_layout.py so there is one definition; they stay in this
|
|
# module's namespace and __all__ so the eight scoreboards that delegate to
|
|
# `sports_card.crisp_size` are untouched.
|
|
|
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
|
default: Any = None) -> Any:
|
|
"""Read one key from the scroll_card config block."""
|
|
block = (config or {}).get("scroll_card")
|
|
if isinstance(block, dict) and block.get(key) is not None:
|
|
return block.get(key)
|
|
return default
|
|
|
|
|
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
|
|
|
|
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Colour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
|
default: Tuple[int, int, int] = (255, 255, 255),
|
|
mode: Optional[str] = None):
|
|
"""Per-element text colour from customization.<element>.text_color.
|
|
|
|
Delegates to src.element_style.element_color, which also resolves the
|
|
element under the names plugins actually use (the layout block says
|
|
`score` where the style block says `score_text`) and honours a per-mode
|
|
override. Hex strings are accepted.
|
|
"""
|
|
from src.element_style import element_color as _shared
|
|
return _shared(config, element, default, mode)
|
|
|
|
|
|
def resolve_font_color(config: Optional[Dict[str, Any]],
|
|
fonts: Optional[Dict[str, Any]], font,
|
|
default: Tuple[int, int, int],
|
|
element_for_font: Dict[str, str],
|
|
mode: Optional[str] = None):
|
|
"""Colour for whichever element owns this face.
|
|
|
|
Identity matching is a stand-in for the element name, used where the draw
|
|
site only ever received a font. Prefer ``element=`` on the draw call; this
|
|
is the fallback for the sites that have not been annotated yet.
|
|
|
|
One object can legitimately belong to several elements -- a size resolver
|
|
can land two of them on the same face, and a BDF face cannot be un-shared
|
|
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
|
|
Ambiguity is therefore narrowed before it is given up on: among the
|
|
elements sharing a face, a single configured colour is the only thing the
|
|
user can have meant, and several that agree mean the same thing. Only a
|
|
genuine disagreement falls back to *default* -- otherwise an element
|
|
drawn in any of the shipped bitmap fonts could lose a colour the user set.
|
|
|
|
The element vocabulary is a parameter because the two callers disagree
|
|
about it -- the mixin's map says ``team_text`` where this module's says
|
|
``team_name`` -- and quietly re-pointing either at the other's names would
|
|
change which colour setting a live install honours.
|
|
"""
|
|
try:
|
|
fonts = fonts or {}
|
|
matches = [element for key, element in element_for_font.items()
|
|
if fonts.get(key) is font]
|
|
if len(matches) == 1:
|
|
return element_color(config, matches[0], default, mode)
|
|
if len(matches) > 1:
|
|
configured = []
|
|
for element in matches:
|
|
colour = element_color(config, element, None, mode)
|
|
if colour is not None and colour not in configured:
|
|
configured.append(colour)
|
|
if len(configured) == 1:
|
|
return configured[0]
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
return default
|
|
|
|
|
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
|
font, default: Tuple[int, int, int] = (255, 255, 255),
|
|
mode: Optional[str] = None):
|
|
"""Colour for whichever element owns this face, by this module's map."""
|
|
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
|
|
mode)
|
|
|
|
|
|
def coerce_rgb(value, fallback):
|
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
|
# iterate into three digits and yield a colour rather than the fallback.
|
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
|
return fallback
|
|
try:
|
|
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
return (r, g, b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Favourite teams
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
|
"""Favorite teams that apply to this game.
|
|
|
|
Both sources are used. Games carry the league manager's *resolved*
|
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
|
appear expanded; the config is read as well so an edit takes effect on
|
|
already-fetched games, and so hand-built game dicts (tests, other
|
|
callers) still work.
|
|
"""
|
|
favorites = list(game.get("favorite_teams") or [])
|
|
league_config = config.get(str(game.get("league", "") or ""))
|
|
if isinstance(league_config, dict):
|
|
favorites += list(league_config.get("favorite_teams") or [])
|
|
else:
|
|
favorites += list(config.get("favorite_teams") or [])
|
|
return favorites
|
|
|
|
|
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
|
"""Is the home/away side of this game a favorite team?
|
|
|
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
|
payload shapes, and matches on the ESPN id too, because a couple of
|
|
leagues (NRL) key favorites by id where abbreviations collide.
|
|
"""
|
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
|
team = game.get(f"{side}_team")
|
|
if isinstance(team, dict):
|
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
|
for value in candidates:
|
|
if value is not None and str(value).strip().upper() in favorites:
|
|
return True
|
|
return False
|
|
|
|
|
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
|
"""Numeric score for one side, from either payload shape."""
|
|
raw = None
|
|
team = game.get(f"{side}_team")
|
|
if isinstance(team, dict) and team.get("score") is not None:
|
|
raw = team.get("score")
|
|
if raw is None:
|
|
raw = game.get(f"{side}_score")
|
|
try:
|
|
return int(float(str(raw).strip()))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> 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 = {
|
|
str(team).strip().upper()
|
|
for team in favorite_teams_for(config, game)
|
|
if str(team).strip()
|
|
}
|
|
if not favorites:
|
|
return None
|
|
|
|
home_fav = side_is_favorite(game, "home", favorites)
|
|
away_fav = side_is_favorite(game, "away", favorites)
|
|
if home_fav == away_fav:
|
|
return None
|
|
|
|
home_score = side_score(game, "home")
|
|
away_score = side_score(game, "away")
|
|
if home_score is None or away_score is None:
|
|
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(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
|
try:
|
|
settings = (config.get("customization") or {}).get(
|
|
"favorite_result_colors"
|
|
) or {}
|
|
if not settings.get("enabled", False):
|
|
return default
|
|
result = favorite_result(config, game)
|
|
if result is None:
|
|
return default
|
|
return coerce_rgb(
|
|
settings.get(f"{result}_color"),
|
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
|
)
|
|
except Exception:
|
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
|
return default
|
|
|
|
|
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
|
game_type: str, default=None):
|
|
"""Fill color for a game card's score. Only finished games are tinted.
|
|
|
|
The default is the configured score colour rather than a flat white,
|
|
so customization.score_text.text_color shows on games the favourite
|
|
tint does not apply to. The tint still wins where it applies.
|
|
"""
|
|
if default is None:
|
|
default = element_color(config, 'score_text')
|
|
if game_type != "recent":
|
|
return default
|
|
return recent_score_color(config, logger, game, default)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Date and time
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
|
configured = (config or {}).get("timezone")
|
|
if configured:
|
|
try:
|
|
return ZoneInfo(configured)
|
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
|
# config should fall back to UTC, not blank the card.
|
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
|
return timezone.utc
|
|
|
|
|
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
|
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 WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
|
except (ValueError, TypeError):
|
|
return ""
|
|
|
|
|
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
|
game: Optional[Dict] = None) -> str:
|
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
|
raw = str(date_text or "").strip()
|
|
if not raw:
|
|
return ""
|
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
|
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
|
|
|
|
|
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
|
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
|
|
|
The body both date formatters share. They differ in which setting names the
|
|
style and in which zone the weekday is taken from (see
|
|
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
|
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
|
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
|
"""
|
|
if fmt == "numeric":
|
|
return raw
|
|
parts = raw.replace("-", "/").split("/")
|
|
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
|
return raw
|
|
month, day = int(parts[0]), int(parts[1])
|
|
if not 1 <= month <= 12:
|
|
return raw
|
|
name = months[month - 1]
|
|
if fmt == "numeric_day_first":
|
|
return f"{day}/{month}"
|
|
if fmt == "day_first":
|
|
return f"{day} {name}"
|
|
if fmt == "weekday":
|
|
day_name = weekday()
|
|
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
|
return f"{name} {day}"
|
|
|
|
|
|
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
|
"""Return the time as-is (12h) or converted to 24h."""
|
|
raw = str(time_text or "").strip()
|
|
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
|
return raw
|
|
cleaned = raw.upper().replace(" ", "")
|
|
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
|
if not meridiem:
|
|
return raw
|
|
try:
|
|
hh, _, mm = cleaned[:-2].partition(":")
|
|
hour, minute = int(hh), int(mm or 0)
|
|
except ValueError:
|
|
return raw
|
|
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
|
return raw
|
|
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
|
return f"{hour:02d}:{minute:02d}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Font sizing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
|
#: global because each plugin declares its own defaults; keyed rather than
|
|
#: per-class because the helper has no class to hang it on.
|
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
|
|
|
|
|
def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]:
|
|
"""``{element: font_size default}`` from a config_schema.json. Raises.
|
|
|
|
The parse both schema-default lookups share. Each keeps its own cache --
|
|
this function per schema path, ``SportsCoreSharedMixin._schema_font_size``
|
|
per class -- because the lifetimes differ: a class is rebuilt when the
|
|
display service reloads a plugin, a module-level path cache is not. One
|
|
cache would change when a reloaded plugin sees an edited schema.
|
|
"""
|
|
import json
|
|
with open(schema_path) as fh:
|
|
schema = json.load(fh)
|
|
props = (schema.get('properties', {})
|
|
.get('customization', {})
|
|
.get('properties', {}))
|
|
sizes: Dict[str, int] = {}
|
|
for key, spec in props.items():
|
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
|
if size is not None:
|
|
sizes[key] = int(size)
|
|
return sizes
|
|
|
|
|
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
|
"""The font_size this plugin's config_schema.json declares, or None.
|
|
|
|
Cached per schema path. The plugins cached this on their own class; the
|
|
path is the same distinction expressed without one, so two plugins never
|
|
share an entry.
|
|
"""
|
|
if not element_key:
|
|
return None
|
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
|
if cache is None:
|
|
try:
|
|
cache = _read_schema_font_sizes(schema_path)
|
|
except Exception as exc:
|
|
# See sports_shared._schema_font_size: an unreadable schema
|
|
# silently disables the pixel-grid snap for every element.
|
|
# Built once per schema path, so this cannot repeat per frame.
|
|
logger.warning(
|
|
"could not read %s (%s: %s); font sizes will skip their "
|
|
"pixel grid snap and may render a pixel narrow",
|
|
schema_path, type(exc).__name__, exc)
|
|
cache = {}
|
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
|
return cache.get(element_key)
|
|
|
|
|
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
|
default_size, font_name, aliases=None, grid_table=None):
|
|
"""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 != schema_font_size(schema_path, element_key):
|
|
return configured
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
|
|
|
|
|
def unshare_element_fonts(logger, fonts, element_for_font=None):
|
|
"""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; resolve_font_color then picks their colour.
|
|
|
|
*element_for_font* names the font keys to consider, in order (the first
|
|
holder of a face keeps it); it defaults to this module's
|
|
:data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map,
|
|
which names different keys -- see ``resolve_font_color`` for why the two
|
|
vocabularies are kept apart.
|
|
"""
|
|
# Looked up at call time so tests can spy on the pinned loader.
|
|
from src.common.font_layout import load_truetype
|
|
if element_for_font is None:
|
|
element_for_font = ELEMENT_FOR_FONT
|
|
seen = {}
|
|
for key in element_for_font:
|
|
font = fonts.get(key)
|
|
if font is None:
|
|
continue
|
|
if id(font) not in seen:
|
|
seen[id(font)] = key
|
|
continue
|
|
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
|
if not path or not size:
|
|
continue
|
|
try:
|
|
fonts[key] = load_truetype(path, size)
|
|
except (OSError, ValueError, TypeError):
|
|
logger.debug(
|
|
"Could not un-share the %s face; it keeps the default colour", key)
|
|
return fonts
|