mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix: /errors stack traces, Wi-Fi disconnect and save, plugin fonts, API cache TTL (#636)
* 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>
This commit is contained in:
+41
-41
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
API Helper
|
||||
|
||||
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
HTTP requests, response caching and ESPN fetch helpers for plugins
|
||||
(``from src.common import APIHelper``), plus the headers every core request
|
||||
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -36,13 +37,20 @@ DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
|
||||
|
||||
class APIHelper:
|
||||
"""
|
||||
Helper class for HTTP requests, caching, and ESPN API integration.
|
||||
|
||||
Provides functionality for:
|
||||
- HTTP requests with retry logic and timeouts
|
||||
- Response caching with TTL support
|
||||
- ESPN API integration for sports data
|
||||
- Request rate limiting and throttling
|
||||
HTTP requests with retries, response caching and ESPN helpers.
|
||||
|
||||
- Requests go through one ``requests.Session`` that retries GET, HEAD
|
||||
and OPTIONS on 429 and 5xx with exponential backoff, and sends
|
||||
:data:`DEFAULT_HTTP_HEADERS`.
|
||||
- Consecutive requests from one helper are spaced at least
|
||||
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
|
||||
does not count.
|
||||
- With a ``cache_manager``, :meth:`get` caches the parsed JSON under
|
||||
``cache_key`` for ``cache_ttl`` seconds. The lifetime is stored with
|
||||
the entry, so CacheManager honours it on every later read, whatever
|
||||
max_age that read asks for.
|
||||
- Failed requests are logged and return None; nothing here raises for a
|
||||
network or HTTP error.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager=None, default_timeout: int = 30,
|
||||
@@ -73,13 +81,7 @@ class APIHelper:
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# Default headers
|
||||
self.session.headers.update({
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Connection': 'keep-alive'
|
||||
})
|
||||
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||
|
||||
# Rate limiting
|
||||
self._last_request_time = 0
|
||||
@@ -102,9 +104,8 @@ class APIHelper:
|
||||
Returns:
|
||||
Response data as dictionary or None if request fails
|
||||
"""
|
||||
# Check cache first
|
||||
if cache_key and self.cache_manager:
|
||||
cached = self._get_from_cache(cache_key)
|
||||
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||
if cached is not None:
|
||||
self.logger.debug(f"Using cached response for {cache_key}")
|
||||
return cached
|
||||
@@ -268,33 +269,31 @@ class APIHelper:
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Time-to-live in seconds (ignored - CacheManager doesn't support TTL)
|
||||
ttl: Seconds the entry stays valid. Stored with the entry, so
|
||||
it applies to every later read of ``key``.
|
||||
"""
|
||||
if self.cache_manager:
|
||||
self.cache_manager.set(key, data)
|
||||
self._set_cache(key, data, ttl)
|
||||
|
||||
def get_cache(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get cached data.
|
||||
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
|
||||
Returns:
|
||||
Cached data or None if not found
|
||||
Cached data, or None if there is none or it has expired. An
|
||||
entry written with a ttl (set_cache, get) expires after that ttl;
|
||||
one written without expires after CacheManager's default max_age.
|
||||
"""
|
||||
if self.cache_manager:
|
||||
return self.cache_manager.get(key)
|
||||
return None
|
||||
return self._get_from_cache(key)
|
||||
|
||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache data.
|
||||
|
||||
Uses CacheManager's real surface (clear_cache / delete /
|
||||
list_cache_files); safely no-ops on managers without it. The old
|
||||
implementation guarded on a nonexistent ``clear`` method, so it
|
||||
silently never cleared anything.
|
||||
Uses CacheManager's clear_cache(), or list_cache_files() and delete()
|
||||
for a pattern. A cache manager without those methods is left alone.
|
||||
|
||||
Args:
|
||||
pattern: Optional substring to match cache keys; only matching
|
||||
@@ -315,21 +314,22 @@ class APIHelper:
|
||||
"cannot clear by pattern")
|
||||
elif hasattr(self.cache_manager, 'clear_cache'):
|
||||
self.cache_manager.clear_cache()
|
||||
elif hasattr(self.cache_manager, 'clear'):
|
||||
self.cache_manager.clear()
|
||||
else:
|
||||
self.logger.debug("Cache manager exposes no clear method; no-op")
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get data from cache."""
|
||||
if self.cache_manager:
|
||||
def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
|
||||
"""Cached data for ``key``, or None. ``max_age`` only matters for an
|
||||
entry stored without a ttl; one stored with a ttl uses that."""
|
||||
if not self.cache_manager:
|
||||
return None
|
||||
if max_age is None:
|
||||
return self.cache_manager.get(key)
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, data: Any, ttl: int) -> None:
|
||||
"""Set data in cache."""
|
||||
return self.cache_manager.get(key, max_age=max_age)
|
||||
|
||||
def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
|
||||
"""Store ``data`` under ``key`` for ``ttl`` seconds."""
|
||||
if self.cache_manager:
|
||||
self.cache_manager.set(key, data)
|
||||
self.cache_manager.set(key, data, ttl=ttl)
|
||||
|
||||
def _enforce_rate_limit(self) -> None:
|
||||
"""Enforce rate limiting between requests."""
|
||||
|
||||
@@ -67,10 +67,11 @@ _INSTALL_ROOT = Path(__file__).resolve().parents[2]
|
||||
def resolve_asset_path(relative_path: str) -> str:
|
||||
"""Resolve a repo-relative asset path independently of the process cwd.
|
||||
|
||||
Prefers the path as given — so an absolute path is returned untouched and
|
||||
behaviour is unchanged wherever the cwd already happened to be the install
|
||||
root — then the install root derived above, then the original string so a
|
||||
caller that wants to raise and fall back still can.
|
||||
In order: an absolute path that exists is returned untouched; otherwise
|
||||
``relative_path`` under the install root derived above, if that exists;
|
||||
otherwise ``relative_path`` unchanged, so a caller that wants to raise
|
||||
and fall back still can. The cwd is never consulted, so a relative path
|
||||
means the same file whichever directory the process started in.
|
||||
|
||||
Without the fallback, any process started outside the install root (the
|
||||
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
|
||||
|
||||
+33
-52
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageDraw
|
||||
from src.common.api_helper import USER_AGENT
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
@@ -32,35 +32,14 @@ from src.common.permission_utils import (
|
||||
# trade for not re-warning about a file nobody is going to add.
|
||||
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
||||
|
||||
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
|
||||
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
|
||||
MIN_LOGO_SCALE = 0.05
|
||||
MAX_LOGO_SCALE = 8.0
|
||||
|
||||
|
||||
def _usable_scale(scale) -> float:
|
||||
"""A scale that can be applied, or 1.0.
|
||||
|
||||
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
|
||||
means "as shipped", because the alternative is a blank panel from a
|
||||
mistyped number.
|
||||
"""
|
||||
try:
|
||||
value = float(scale)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
if value != value or value in (float('inf'), float('-inf')):
|
||||
return 1.0
|
||||
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
|
||||
return 1.0
|
||||
return value
|
||||
|
||||
|
||||
|
||||
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
|
||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||
|
||||
#: A logo's default bounding box, as a multiple of the panel's width and
|
||||
#: height, when the caller gives no max_width / max_height.
|
||||
DEFAULT_LOGO_BOX_FACTOR = 1.5
|
||||
|
||||
|
||||
class LogoHelper:
|
||||
"""
|
||||
@@ -119,12 +98,16 @@ class LogoHelper:
|
||||
Args:
|
||||
team_abbr: Team abbreviation for caching
|
||||
logo_path: Path to the logo file
|
||||
max_width: Maximum width (defaults to display_width * 1.5)
|
||||
max_height: Maximum height (defaults to display_height * 1.5)
|
||||
max_width: Maximum width (default display_width *
|
||||
DEFAULT_LOGO_BOX_FACTOR)
|
||||
max_height: Maximum height (default display_height *
|
||||
DEFAULT_LOGO_BOX_FACTOR)
|
||||
scale: User's size multiplier for this image, from
|
||||
``customization.layout.<element>.scale``. 1.0 is untouched and
|
||||
takes exactly the path it always did. Callers hold the config,
|
||||
so they resolve the element name; this only applies the number.
|
||||
``customization.layout.<element>.scale``; 1.0 leaves the box
|
||||
as is. Callers hold the config, so they resolve the element
|
||||
name; this only applies the number, clamped to
|
||||
src.element_style's MIN_ELEMENT_SCALE..MAX_ELEMENT_SCALE.
|
||||
A value that is not a finite positive number means 1.0.
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if loading fails
|
||||
@@ -138,10 +121,13 @@ class LogoHelper:
|
||||
# key is size-qualified — a panel-size change must not return a
|
||||
# logo resized for the old dimensions.
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
scale = _usable_scale(scale)
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
# Imported here: src.element_style imports src.common (for bdf_font),
|
||||
# whose __init__ imports this module.
|
||||
from src.element_style import coerce_scale
|
||||
scale = coerce_scale(scale, 1.0)
|
||||
if scale != 1.0:
|
||||
max_width = max(1, int(round(max_width * scale)))
|
||||
max_height = max(1, int(round(max_height * scale)))
|
||||
@@ -374,9 +360,9 @@ class LogoHelper:
|
||||
nobody asked to grow would change every existing render.
|
||||
"""
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
|
||||
# Only resize if necessary
|
||||
if logo.width <= max_width and logo.height <= max_height:
|
||||
@@ -429,31 +415,26 @@ class LogoHelper:
|
||||
max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
||||
"""
|
||||
Create a placeholder logo with team abbreviation.
|
||||
|
||||
A stand-in for a logo that could not be loaded or downloaded: a
|
||||
translucent grey box with a light outline, filling the logo box.
|
||||
No text is drawn; ``team_abbr`` is only used in log messages.
|
||||
|
||||
Args:
|
||||
team_abbr: Team abbreviation to display
|
||||
max_width: Maximum width
|
||||
max_height: Maximum height
|
||||
|
||||
team_abbr: Team the placeholder stands in for
|
||||
max_width: Width (default display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
max_height: Height (default display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
|
||||
Returns:
|
||||
PIL Image with placeholder logo
|
||||
The RGBA placeholder, or None if it could not be created
|
||||
"""
|
||||
try:
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
|
||||
# Create placeholder image
|
||||
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
|
||||
|
||||
# This would require a font, so we'll create a simple colored rectangle
|
||||
# In a real implementation, you'd want to add text rendering here
|
||||
from PIL import ImageDraw
|
||||
draw = ImageDraw.Draw(placeholder)
|
||||
|
||||
# Draw a simple rectangle with team abbreviation
|
||||
draw.rectangle([0, 0, max_width-1, max_height-1],
|
||||
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
||||
|
||||
|
||||
@@ -241,7 +241,8 @@ def get_assets_dir_mode() -> int:
|
||||
Return permission mode for asset directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
@@ -251,7 +252,8 @@ def get_config_dir_mode() -> int:
|
||||
Return permission mode for config directory.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
@@ -271,7 +273,8 @@ def get_plugin_dir_mode() -> int:
|
||||
Return permission mode for plugin directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
@@ -281,7 +284,8 @@ def get_cache_dir_mode() -> int:
|
||||
Return permission mode for cache directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ serves two consumers with different needs:
|
||||
|
||||
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
||||
fresh frames — but only while a browser is actually watching.
|
||||
- The health check (web_interface/blueprints/api_v3.py, hardware status)
|
||||
- The health check (web_interface/blueprints/api_v3/misc.py, hardware status)
|
||||
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
||||
|
||||
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
||||
@@ -27,7 +27,7 @@ Policy:
|
||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||
|
||||
If any constant here changes, re-check the health threshold in
|
||||
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
@@ -37,7 +37,7 @@ VIEWER_INTERVAL = 0.2
|
||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||
IDLE_INTERVAL = 30.0
|
||||
# Max age of the last write/touch before bumping mtime for the health
|
||||
# check. MUST stay well under api_v3's 60s degraded threshold.
|
||||
# check. MUST stay well under get_hardware_status's 60s degraded threshold.
|
||||
TOUCH_INTERVAL = 20.0
|
||||
# A viewer marker older than this no longer counts as a live viewer.
|
||||
VIEWER_MARKER_FRESH_SEC = 5.0
|
||||
|
||||
+11
-15
@@ -101,11 +101,10 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||
mode: Optional[str] = None):
|
||||
"""Per-element text colour from customization.<element>.text_color.
|
||||
|
||||
Delegated rather than reimplemented: there were two copies of this
|
||||
read and three of the offset read, and the shared one 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 still accepted.
|
||||
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)
|
||||
@@ -125,12 +124,11 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
|
||||
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.
|
||||
Those draws used to go out white, which is how an element rendered in any
|
||||
of the 32 shipped bitmap fonts could silently lose a colour the user had
|
||||
set. So ambiguity is now narrowed before it is given up on: among the
|
||||
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*.
|
||||
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
|
||||
@@ -483,7 +481,7 @@ def unshare_element_fonts(logger, fonts, element_for_font=None):
|
||||
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.
|
||||
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
|
||||
@@ -491,10 +489,8 @@ def unshare_element_fonts(logger, fonts, element_for_font=None):
|
||||
which names different keys -- see ``resolve_font_color`` for why the two
|
||||
vocabularies are kept apart.
|
||||
"""
|
||||
try:
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
# 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 = {}
|
||||
@@ -509,7 +505,7 @@ def unshare_element_fonts(logger, fonts, element_for_font=None):
|
||||
if not path or not size:
|
||||
continue
|
||||
try:
|
||||
fonts[key] = _load(path, size)
|
||||
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)
|
||||
|
||||
@@ -56,21 +56,13 @@ class SportsGameRendererMixin:
|
||||
|
||||
# ---- geometry ------------------------------------------------------
|
||||
|
||||
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
||||
# these from config as a float or a string, passes an `isinstance` plus
|
||||
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
||||
# which the old `except (TypeError, ValueError)` did not catch, so it
|
||||
# aborted the whole card render. Present in all eight plugins before this
|
||||
# moved to the core; fixing it here fixes it in all eight.
|
||||
|
||||
def _score_reserve_width(self) -> int:
|
||||
"""Centre strip the score actually needs, measured rather than assumed.
|
||||
"""Centre strip the score needs: the width of _SCORE_PROBE in the
|
||||
score font plus a gutter each side, or 0 if it cannot be measured.
|
||||
|
||||
The gap was derived from the card width alone (width x
|
||||
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
||||
comes from config and the element-style resolver. Nothing compared the
|
||||
two, so any score wider than the clamp was drawn over the logos.
|
||||
Measuring it keeps the strip wide enough for whatever font is in play.
|
||||
Measured rather than derived from the card width, because the score's
|
||||
size comes from config and the element-style resolver: a strip sized
|
||||
from the width alone lets a large score run over the logos.
|
||||
"""
|
||||
try:
|
||||
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||
@@ -87,6 +79,10 @@ class SportsGameRendererMixin:
|
||||
the card width between the configurable min and max. 0 restores
|
||||
edge-to-edge logos.
|
||||
"""
|
||||
# Non-finite settings are rejected before any int()/round(): "inf"
|
||||
# arrives from config as a float or a string, passes the isinstance
|
||||
# and >= 0 checks, and int() then raises OverflowError, which would
|
||||
# abort the whole card render.
|
||||
configured = self._scroll_card_option("center_gap")
|
||||
if (isinstance(configured, (int, float))
|
||||
and math.isfinite(configured) and configured >= 0):
|
||||
@@ -110,10 +106,9 @@ class SportsGameRendererMixin:
|
||||
def _logo_slot_width(self) -> int:
|
||||
"""Per-side logo slot, leaving the center gap clear.
|
||||
|
||||
No longer capped at display_height: the card is sized as two
|
||||
full-height logos plus the measured gap, so what is left after the gap
|
||||
is exactly the logo's share. The cap was what froze the logos at 46px
|
||||
on the old flat 128px card.
|
||||
Not capped at display_height: the card is sized as two full-height
|
||||
logos plus the measured gap, so what is left after the gap is exactly
|
||||
the logo's share. At least 8 px.
|
||||
"""
|
||||
available = (self.display_width - self._center_gap_width()) // 2
|
||||
return max(8, available)
|
||||
@@ -130,9 +125,8 @@ class SportsGameRendererMixin:
|
||||
"""X/Y nudge for one element, from customization.layout.
|
||||
|
||||
Same block the full-screen scorebug reads (sports.py
|
||||
_get_layout_offset), so a nudge configured in the web UI now moves
|
||||
the element on the scroll/Vegas card too -- previously the schema
|
||||
advertised these offsets but this renderer ignored them.
|
||||
_get_layout_offset), so a nudge configured in the web UI moves the
|
||||
element on the scroll/Vegas card as well as on the scorebug.
|
||||
"""
|
||||
from src.element_style import layout_offset
|
||||
return layout_offset(self.config, element, axis, default,
|
||||
|
||||
@@ -487,9 +487,9 @@ class SportsScrollDisplayManager:
|
||||
)
|
||||
except Exception:
|
||||
# prepare_scroll_content is subclass-implemented and builds cards
|
||||
# straight from feed data, which is exactly where this PR's other
|
||||
# crashes came from. One sport's bad payload must not take down the
|
||||
# shared orchestration for the others.
|
||||
# straight from feed data, so it can raise on a malformed payload.
|
||||
# One sport's bad payload must not take down the shared
|
||||
# orchestration for the others.
|
||||
self.logger.exception(
|
||||
"Error preparing scroll content for game_type=%s", game_type
|
||||
)
|
||||
|
||||
+15
-37
@@ -97,11 +97,11 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image, ImageDraw
|
||||
from src.common import sports_card as _card
|
||||
from src.common.font_layout import load_truetype
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -132,36 +132,16 @@ def _resolve_font_path(path: str) -> str:
|
||||
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 matches the core's own resolver: the path as given
|
||||
first, so behaviour is unchanged wherever it already worked and a
|
||||
configured absolute path is returned untouched, then the core install
|
||||
root, then the original string so callers still raise and fall back
|
||||
exactly as they do today.
|
||||
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
|
||||
try:
|
||||
import src.font_manager as _core_fonts
|
||||
|
||||
# The core grew this resolver in ChuckBuilds/LEDMatrix#425. Use it
|
||||
# when it is there so both repos stay on one definition of "install
|
||||
# root"; older cores fall through to the equivalent derivation below.
|
||||
manager = getattr(_core_fonts, "FontManager", None)
|
||||
resolver = getattr(manager, "_resolve_asset_path", None)
|
||||
if resolver is not None:
|
||||
resolved = resolver(path)
|
||||
if resolved and os.path.exists(resolved):
|
||||
return resolved
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
|
||||
candidate = os.path.join(root, path)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
except (ImportError, AttributeError, OSError):
|
||||
# No core on the path (standalone tooling), a core laid out
|
||||
# differently, or an unreadable install. Returning the original keeps
|
||||
# the caller's existing fallback intact.
|
||||
return path
|
||||
return path
|
||||
return resolve_asset_path(path)
|
||||
|
||||
|
||||
class SportsCoreSharedMixin:
|
||||
@@ -201,9 +181,6 @@ class SportsCoreSharedMixin:
|
||||
#: How long to stay quiet between ranking-coverage warnings.
|
||||
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
|
||||
|
||||
def _get_season_schedule_dates(self) -> tuple[str, str]:
|
||||
return "", ""
|
||||
|
||||
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
|
||||
@@ -889,7 +866,10 @@ class SportsCoreSharedMixin:
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
||||
"""Check if we should log a warning based on cooldown period."""
|
||||
"""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
|
||||
@@ -904,8 +884,6 @@ class SportsCoreSharedMixin:
|
||||
try:
|
||||
# Fetch current week and next few days for immediate display
|
||||
now = datetime.now(pytz.utc)
|
||||
immediate_events = []
|
||||
|
||||
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')}"
|
||||
@@ -913,7 +891,7 @@ class SportsCoreSharedMixin:
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session,
|
||||
url,
|
||||
params={"dates": date_str, "limit": 1000},
|
||||
params={"dates": date_str, "limit": ESPN_MAX_LIMIT},
|
||||
headers=self.headers,
|
||||
timeout=10,
|
||||
logger=self.logger,
|
||||
|
||||
+29
-27
@@ -32,7 +32,7 @@ from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
|
||||
|
||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
||||
@@ -75,9 +75,12 @@ class FollowerState(Enum):
|
||||
class DisplaySyncManager:
|
||||
"""
|
||||
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
||||
Leader sends compressed PNG frames to the follower after each render cycle.
|
||||
Follower renders received frames; returns to own plugin stack when leader
|
||||
goes offline.
|
||||
|
||||
The leader sends each rendered frame to the follower over UDP as raw RGB
|
||||
bytes (send_frame), and for Vegas scrolling sends the whole scroll image
|
||||
once per cycle as a PNG over TCP on port + 1 (send_scroll_image), then
|
||||
only the scroll position. The follower draws what it receives and goes
|
||||
back to its own plugins when the leader stops sending.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -192,8 +195,8 @@ class DisplaySyncManager:
|
||||
|
||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||
hw = self._hw_config
|
||||
local_rows = hw.get("rows", 32)
|
||||
local_cols = hw.get("cols", 64)
|
||||
local_rows = hw.get("rows", DEFAULT_ROWS)
|
||||
local_cols = hw.get("cols", DEFAULT_COLS)
|
||||
peer_rows = int(msg.get("rows", 0))
|
||||
peer_cols = int(msg.get("cols", 0))
|
||||
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
||||
@@ -469,16 +472,23 @@ class DisplaySyncManager:
|
||||
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||
with self._frame_lock:
|
||||
self._latest_frame = img
|
||||
self._enter_follower_mode(sender_ip)
|
||||
|
||||
def _enter_follower_mode(self, sender_ip: str) -> bool:
|
||||
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||
switch from standalone to follower mode if not already following.
|
||||
Returns True if this call made the switch."""
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
if self._follower_state != FollowerState.STANDALONE:
|
||||
return False
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
return True
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
@@ -559,15 +569,7 @@ class DisplaySyncManager:
|
||||
# back from. Treat it as malformed.
|
||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||
self._latest_scroll_x = scroll_x
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
if self._enter_follower_mode(sender_ip):
|
||||
fire_new_cycle = True # build initial scroll image
|
||||
elif t == "nc":
|
||||
# Leader started a new scroll cycle — rebuild local image
|
||||
@@ -589,8 +591,8 @@ class DisplaySyncManager:
|
||||
hw = self._hw_config
|
||||
hello = json.dumps({
|
||||
"t": "hello",
|
||||
"rows": hw.get("rows", 32),
|
||||
"cols": hw.get("cols", 64),
|
||||
"rows": hw.get("rows", DEFAULT_ROWS),
|
||||
"cols": hw.get("cols", DEFAULT_COLS),
|
||||
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||
}).encode("utf-8")
|
||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||
@@ -660,8 +662,8 @@ class DisplaySyncManager:
|
||||
base = {
|
||||
"role": self.role.value,
|
||||
"port": self.port,
|
||||
"local_rows": hw.get("rows", 32),
|
||||
"local_cols": hw.get("cols", 64),
|
||||
"local_rows": hw.get("rows", DEFAULT_ROWS),
|
||||
"local_cols": hw.get("cols", DEFAULT_COLS),
|
||||
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||
}
|
||||
|
||||
|
||||
+26
-22
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
@@ -18,13 +18,14 @@ _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
Helper class for text rendering with outlines and font management.
|
||||
|
||||
Provides functionality for:
|
||||
- Loading and managing fonts
|
||||
- Drawing text with outlines for better readability
|
||||
- Calculating text dimensions and positioning
|
||||
- Managing font resources
|
||||
Font loading, outlined text and text measurement for plugins.
|
||||
|
||||
- :meth:`load_fonts` loads TrueType fonts from ``font_dir`` (the install's
|
||||
assets/fonts by default) with the layout engine pinned
|
||||
(font_layout.load_truetype). Each (file, size) is loaded once per helper
|
||||
and reused; a missing or unloadable file becomes PIL's default font.
|
||||
- :meth:`draw_text_with_outline` and friends draw onto a caller's
|
||||
``ImageDraw``; the measuring methods need no canvas.
|
||||
"""
|
||||
|
||||
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
|
||||
@@ -33,22 +34,26 @@ class TextHelper:
|
||||
Initialize the TextHelper.
|
||||
|
||||
Args:
|
||||
font_dir: Directory containing font files (defaults to assets/fonts)
|
||||
font_dir: Directory containing font files. Defaults to the
|
||||
install's assets/fonts, whatever the process cwd is.
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.font_dir = Path(font_dir) if font_dir else Path("assets/fonts")
|
||||
self.font_dir = Path(font_dir) if font_dir else Path(resolve_asset_path("assets/fonts"))
|
||||
# "<path>:<size>" -> loaded font; see load_fonts.
|
||||
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
|
||||
|
||||
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
||||
"""
|
||||
Load fonts for different text elements.
|
||||
|
||||
|
||||
Args:
|
||||
font_config: Custom font configuration dictionary
|
||||
|
||||
font_config: ``{name: {"file": <file in font_dir>, "size": <px>}}``;
|
||||
defaults to the scoreboard set in _get_default_font_config.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping font names to PIL ImageFont objects
|
||||
Dictionary mapping font names to PIL ImageFont objects. A font
|
||||
already loaded by this helper at the same size is reused.
|
||||
"""
|
||||
if font_config is None:
|
||||
font_config = self._get_default_font_config()
|
||||
@@ -61,9 +66,13 @@ class TextHelper:
|
||||
size = config['size']
|
||||
|
||||
if font_path.exists():
|
||||
font = load_truetype(str(font_path), size)
|
||||
cache_key = f"{font_path}:{size}"
|
||||
font = self._font_cache.get(cache_key)
|
||||
if font is None:
|
||||
font = load_truetype(str(font_path), size)
|
||||
self._font_cache[cache_key] = font
|
||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||
fonts[font_name] = font
|
||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||
else:
|
||||
# Fallback to default font
|
||||
font = ImageFont.load_default()
|
||||
@@ -115,12 +124,7 @@ class TextHelper:
|
||||
Returns:
|
||||
Width in pixels
|
||||
"""
|
||||
try:
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
|
||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user