mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness
# Conflicts: # CHANGELOG.md
This commit is contained in:
+217
-37
@@ -1,62 +1,242 @@
|
||||
# Common Utilities
|
||||
# src/common
|
||||
|
||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||
Helpers shared by core and plugins. This page lists every module, what it is
|
||||
for, and whether plugins are expected to import it.
|
||||
|
||||
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
||||
Rules for the package:
|
||||
|
||||
The recommended way to lay out plugins that render legibly on **any** panel
|
||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
||||
from `src.common` for convenience; canonical import paths are
|
||||
`src.adaptive_layout` / `src.adaptive_images`.
|
||||
- Every module must import without display hardware: nothing here may import
|
||||
`src.display_manager` or `src.plugin_system` at module level
|
||||
([`test/test_common_is_hardware_free.py`](../../test/test_common_is_hardware_free.py)).
|
||||
That keeps plugins that use it loadable by the web preview,
|
||||
`scripts/check_plugin.py` and tests on a laptop.
|
||||
- A plugin that imports a module added in a given core release must declare
|
||||
that release as its minimum (`ledmatrix_min_version` in the manifest's
|
||||
`versions` entry). The "Since" column gives the release; "—" means it
|
||||
predates 3.1.0, "n/a" that plugins should not import it.
|
||||
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
|
||||
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
|
||||
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
|
||||
the adaptive layout names below ([`__init__.py`](__init__.py)).
|
||||
|
||||
## Summary
|
||||
|
||||
| Module | For | Plugins import it? | Since |
|
||||
|---|---|---|---|
|
||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
||||
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
||||
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
||||
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
|
||||
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
||||
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
||||
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||
|
||||
The four `sports_*` mixin and card modules hold code the scoreboard plugins
|
||||
used to carry as identical copies. Each module docstring lists what a host
|
||||
class must provide. The plan behind them is in
|
||||
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
|
||||
|
||||
## Adaptive layout and images
|
||||
|
||||
`src/adaptive_layout.py` and `src/adaptive_images.py` live outside this
|
||||
package but are re-exported from `src.common`. They are the recommended way
|
||||
to lay out a plugin that renders legibly on any panel size. Every
|
||||
`BasePlugin` already has `self.layout`, `self.draw_fit()` and
|
||||
`self.draw_image()`:
|
||||
|
||||
```python
|
||||
# Every BasePlugin already has self.layout and the draw helpers:
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||
self.draw_fit(status, regs.status_band)
|
||||
```
|
||||
|
||||
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
Key pieces: `Region`, the font ladders `LADDER_GRID` / `LADDER_ARCADE`,
|
||||
`LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `px`), and
|
||||
`scoreboard_regions()` / `media_row()`. Guide:
|
||||
[docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## API Helpers (`api_helper.py`)
|
||||
## Modules
|
||||
|
||||
Utilities for making HTTP requests and handling API responses.
|
||||
### api_helper
|
||||
|
||||
## Logo Helpers (`logo_helper.py`)
|
||||
[`api_helper.py`](api_helper.py). `APIHelper(cache_manager=None, ...)`:
|
||||
`get()` and `post()` with retries, optional caching through the cache
|
||||
manager, and a minimum interval between requests (`set_rate_limit()`). Also has
|
||||
`fetch_espn_scoreboard()`, `fetch_espn_standings()` and
|
||||
`fetch_espn_rankings()`.
|
||||
|
||||
Utilities for loading and managing team logos.
|
||||
### bdf_font
|
||||
|
||||
## Text Helpers (`text_helper.py`)
|
||||
[`bdf_font.py`](bdf_font.py). The one BDF loader and rasterizer.
|
||||
`load_bdf_face(path, size)` returns `(face, realised_px)`, falling back to
|
||||
the file's native strike when it has none at `size`;
|
||||
`draw_bdf_text(draw, text, x, y, face, color)` draws top-left anchored onto a
|
||||
PIL `ImageDraw` the same way the panel does. `read_bdf_native_size(path)`
|
||||
and `clear_face_cache()` round it out. Faces are cached per thread (FreeType
|
||||
faces are not thread-safe). `DisplayManager`, `FontManager`, `element_style`
|
||||
and the plugin test harness all use it. Most plugins get BDF text through
|
||||
`display_manager.draw_text()` or `FontManager` and never import this.
|
||||
|
||||
Utilities for text processing and formatting.
|
||||
### espn_dates
|
||||
|
||||
## BDF Fonts (`bdf_font.py`)
|
||||
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||
splits a range into month and day requests ESPN accepts and merges the
|
||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Scoreboard plugins also bundle a copy for older cores.
|
||||
|
||||
The one way to load and draw BDF bitmap fonts. `load_bdf_face(path, size)`
|
||||
returns `(face, realised_px)`, falling back to the file's native strike when
|
||||
it has none at `size`; `draw_bdf_text(draw, text, x, y, face, color)` draws
|
||||
top-left anchored onto a PIL `ImageDraw` exactly as the panel does.
|
||||
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
|
||||
all go through it.
|
||||
### font_layout
|
||||
|
||||
## Scroll Helpers (`scroll_helper.py`)
|
||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||
`ImageFont.truetype` with PIL's Basic layout engine pinned, so text lays out
|
||||
the same whether or not the host Pillow has libraqm; use it for anything
|
||||
drawn to the panel or compared against a golden image. `crisp_size()` gives
|
||||
the size a bundled face renders on whole pixels at. `resolve_asset_path()`
|
||||
resolves `assets/fonts/...` against the install root rather than the
|
||||
working directory.
|
||||
|
||||
Utilities for scrolling text on the display.
|
||||
### logo_helper
|
||||
|
||||
## Permission Utilities (`permission_utils.py`)
|
||||
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
|
||||
display_height, ...)`: `load_logo()`, `load_logo_with_download()`,
|
||||
`get_logo_variations()`, `normalize_abbreviation()`, with an in-memory cache.
|
||||
|
||||
Helpers for ensuring directory permissions and ownership are correct
|
||||
when running as a service (used by `CacheManager` to set up its
|
||||
persistent cache directory).
|
||||
### path_safety
|
||||
|
||||
## Best Practices
|
||||
[`path_safety.py`](path_safety.py). Core-internal, used by web handlers that
|
||||
open files named in a request. `safe_path_component(value)` returns the
|
||||
value if it is one harmless path segment, else `None`;
|
||||
`resolve_under(base, *parts)` returns the resolved path, or `None` if a part
|
||||
is unsafe or the result would leave `base`; `safe_relative_parts()` splits a
|
||||
relative path the same way. Both return the sanitised value rather than a
|
||||
boolean, so a caller cannot check one string and open another.
|
||||
|
||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
||||
2. **Reuse utilities**: Check existing utilities before creating new ones
|
||||
3. **Document additions**: Add documentation when adding new utilities
|
||||
### permission_utils
|
||||
|
||||
[`permission_utils.py`](permission_utils.py). The modes and ownership that
|
||||
let the root display service and the web user share files:
|
||||
`ensure_directory_permissions()`, `ensure_file_permissions()`, the
|
||||
`get_*_mode()` functions, `ensure_shared_group_ownership()`,
|
||||
`sudo_remove_directory()` and `install_requirements_file()` (the sudo
|
||||
`safe_pip_install.sh` path). `ConfigManager`, `CacheManager` and the store
|
||||
already call these; a plugin needs them only when it creates its own files
|
||||
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
||||
|
||||
### scroll_config
|
||||
|
||||
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
|
||||
plugin_config=, global_config=, display_manager=, plugin_logger=)` reads a
|
||||
plugin's scroll settings, snaps the speed to a whole number of pixels per
|
||||
panel refresh, puts the helper in fixed-step mode and returns
|
||||
`ScrollSettings`. Pass `settings.frame_hold` to
|
||||
`display_manager.set_scrolling_state(True, frame_hold=...)` or the scroll
|
||||
runs too fast. `resolve()` does the calculation without touching a helper.
|
||||
See [docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
|
||||
|
||||
### scroll_helper
|
||||
|
||||
[`scroll_helper.py`](scroll_helper.py). `ScrollHelper(display_width,
|
||||
display_height, logger=None)`: build a wide image once
|
||||
(`create_scrolling_image()` or `set_scrolling_image()`), then per frame
|
||||
`update_scroll_position()` and `get_visible_portion()`;
|
||||
`is_scroll_complete()`, `calculate_dynamic_duration()` and
|
||||
`get_dynamic_duration()` for timing. Configure it with `scroll_config`
|
||||
rather than the `set_*` methods. Vegas mode reads a plugin's
|
||||
`scroll_helper` image when the plugin has no `get_vegas_content()`.
|
||||
|
||||
### snapshot_policy
|
||||
|
||||
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
||||
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
|
||||
touch its mtime, or skip, based on whether a browser is watching the preview.
|
||||
The web health check reads the file's age.
|
||||
|
||||
### sports_card
|
||||
|
||||
[`sports_card.py`](sports_card.py). Free functions taking `config`, `fonts`
|
||||
and `logger` explicitly: card options (`scroll_card_option()`,
|
||||
`vs_text()`, `upcoming_center_mode()`), colours (`element_color()`,
|
||||
`font_color()`, `score_color_for()`, `recent_score_color()`), favourite-team
|
||||
rules (`favorite_teams_for()`, `side_is_favorite()`, `favorite_result()`),
|
||||
dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
|
||||
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
|
||||
method and delegates the body.
|
||||
|
||||
### sports_game_renderer
|
||||
|
||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||
`SportsGameRendererMixin`: the scroll/Vegas card geometry (centre gap, logo
|
||||
slot, layout offsets, upcoming-card date and time). No `__init__` and no
|
||||
state; add it as a base class of the plugin's game renderer and override
|
||||
what differs.
|
||||
|
||||
### sports_helpers
|
||||
|
||||
[`sports_helpers.py`](sports_helpers.py). Free functions `clamp_window()`,
|
||||
`clamp_seconds()`, `logo_needs_refresh()`, `spread_weighted_order()`, and
|
||||
`SportsHelpersMixin` with the scoreboards' `_mode_customization`,
|
||||
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
|
||||
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
|
||||
Nothing in core uses it.
|
||||
|
||||
### sports_scroll
|
||||
|
||||
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
||||
`SportsScrollDisplayManager`: the scroll-display orchestration the
|
||||
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
|
||||
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
|
||||
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
|
||||
|
||||
### sports_shared
|
||||
|
||||
[`sports_shared.py`](sports_shared.py). `SportsCoreSharedMixin`,
|
||||
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` methods
|
||||
that were identical in every scoreboard (game selection and rotation,
|
||||
fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||
the attributes the host class must have and the three methods deliberately
|
||||
left out.
|
||||
|
||||
### sync_manager
|
||||
|
||||
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
||||
links two displays as leader and follower (`sync.role` in config) over UDP
|
||||
port 5765, plus TCP on the next port for scroll images. The leader drives the
|
||||
scroll and sends the follower its part of each frame; a follower falls back
|
||||
to its own plugins when the leader goes quiet. Rows and columns must match.
|
||||
Created by `DisplayController`; works with any plugin.
|
||||
|
||||
### text_helper
|
||||
|
||||
[`text_helper.py`](text_helper.py). `TextHelper(font_dir=None, ...)`:
|
||||
`load_fonts()`, `draw_text_with_outline()`, `get_text_width()`,
|
||||
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
|
||||
`draw_multiline_text()`, `create_text_image()`.
|
||||
|
||||
## Logging
|
||||
|
||||
Modules here create their logger with `logging.getLogger(__name__)`, which is
|
||||
the same logger `src.logging_config.get_logger(__name__)` returns. The helper
|
||||
classes (`APIHelper`, `LogoHelper`, `ScrollHelper`, `TextHelper`) and
|
||||
`espn_dates` take an optional `logger`. In a plugin, pass `self.logger`: it is
|
||||
created by `get_logger(..., plugin_id=...)` in `BasePlugin`, so messages carry
|
||||
the plugin id.
|
||||
|
||||
## Adding a module
|
||||
|
||||
- Keep it importable without hardware (see the test above).
|
||||
- Give it a module docstring that says what it is for and, if it is a mixin,
|
||||
what the host class must provide.
|
||||
- Add it to the table on this page and, if plugins may import it, to the
|
||||
CHANGELOG with the release to floor on.
|
||||
|
||||
+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