mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-11 21:58:05 +00:00
* refactor(web): use canonical secret helpers in api_v3; make ConfigManager secret strip/merge array-aware
api_v3.py carried three inline nested copies of find_secret_fields/
separate_secrets (main-config save, plugin-config save, plugin-config
reset). They drifted from each other (one lacked isinstance guards) and
none supported the canonical module's array-item secrets
(accounts[].token). All three endpoints now import from
src/web_interface/secret_helpers.
Adopting the canonical behavior makes array-item secrets reachable, and
their parallel-placeholder shape ([{'token': ...}, {}] alongside the
regular list) was not survivable by ConfigManager's round-trip:
_strip_secrets_recursive dropped the whole key (losing the regular
fields from config.json) and _deep_merge replaced the regular list
wholesale on load. Both are now array-aware:
- strip removes the secret fields from each item and ALWAYS keeps the
list so indices survive for merge-on-load; whole-key secrets (scalar
lists, shape mismatches) still drop the key entirely — never leak.
- merge folds each secrets item into the config item at the same index,
skipping {} placeholders. The regular list's length is authoritative
in both directions: a user deleting an array item never has it
resurrected from a stale secrets entry (extras warn and are ignored).
api_v3's own deep_merge intentionally still replaces lists wholesale —
form posts carry complete arrays and index-merging would resurrect
deleted items; a comment now documents that.
Tests: the parity guard flips from 'exactly 3 inline copies' to 'zero,
and the canonical import must exist'; TestArraySecretStripAndMerge
covers the new strip/merge semantics incl. length-mismatch contracts;
new test_api_v3_secret_roundtrip.py drives all three endpoints through
a Flask client with a REAL ConfigManager+SchemaManager over tmp_path,
proving secrets land in config_secrets.json, config.json stays clean,
and a fresh load merges them back into the right array items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* fix: repair broken helper paths across display, cache, odds, logging, resolver, repos, config, validator
Nine fixes for bugs surfaced while writing coverage for previously
untested modules (plus the bool-duration quirk pinned in PR #441):
- base_plugin.get_display_duration: exclude bools from both numeric
branches — display_duration=True no longer reads as a 1-second slot;
it falls through to config, then the 15.0 default.
- display_helper: draw_error_message/draw_no_data_message called
_draw_centered_text with the wrong arguments and crashed with
AttributeError — both now delegate to draw_centered_text.
draw_scorebug_layout drew status and clock at the same y, overprinting
each other — they now share one combined top line.
draw_ticker_layout drew its text starting at x=display_width (fully
off-canvas), returning a blank frame every time — now draws at x=0;
scroll_speed stays accepted-but-unused and is documented as such.
- api_helper.clear_cache guarded on a nonexistent CacheManager.clear()
method, silently never clearing anything; it now uses the real surface
(clear_cache/delete/list_cache_files) and no-ops safely otherwise.
- base_odds_manager._extract_espn_data raised AttributeError when ESPN
sent explicit JSON nulls ("homeTeamOdds": null) — every level now
null-safes with 'or {}'. format_odds_summary gated on
is_odds_available, which deliberately ignores money lines, so
ML-only odds formatted as "No odds available" — it now gates only on
empty/no_odds data and formats money lines.
- logging_config.ContextualFormatter mutated record.msg in place, so a
second handler prepended the context prefix twice; it now formats a
copy. log_error hardcoded exc_info=True and raised TypeError when the
caller passed exc_info — now kwargs.setdefault.
- dynamic_team_resolver wrote its "shared" class cache through self,
creating instance shadows — the cache was per-instance and every
scoreboard refetched rankings. Writes now go through the class.
- saved_repositories cleaned URLs with an unanchored .replace('.git','')
that mangled URLs merely containing '.git' (my.github.io -> myhub.io);
now strips only a trailing suffix. add/remove also roll back the
in-memory list when the save fails, so memory always matches disk.
- config_helper.merge_configs shallow-copied the base, aliasing every
un-overridden nested dict into the result — now deep-copies.
- startup_validator.validate_all accumulated errors/warnings across
calls — now resets both lists per run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* test: cover the previously untested modules
Nine new suites plus an extension, asserting the Phase-1b fixed behavior
and pinning the quirks deliberately left alone:
- test_logging_config.py: formatters (JSON shape, no record mutation,
single prefix through two handlers), PluginLoggerAdapter precedence,
setup_logging handler hygiene and LEDMATRIX_DEBUG, log_error exc_info.
- test_startup_validator.py: exact messages, error-vs-warning split,
accessor split (load_config vs get_config), cache-dir branches with
os.access monkeypatched (root can write anything in CI), idempotence,
raise_on_errors classification precedence.
- test_config_helper.py (full): load/save round trips, dot-notation
get/set incl. silent-failure contract, post-fix no-aliasing merge,
schema validation branches, the '{id}_config' key pin, default-enabled
pin.
- test_saved_repositories.py: three load shapes, bare-list rewrite pin,
trailing-only .git strip (my.github.io regression), save-failure
rollback, type-classification case-sensitivity pin.
- test_api_helper.py: rate-limit math, cache-hit short circuit, ESPN
URL/key formats, exact User-Agent guard, retry adapter, post-fix
clear_cache against the real CacheManager surface, ttl-dropped pin.
- test_base_odds_manager.py: cache-key/URL construction, no_odds
sentinel round trip, stale-cache fallback, null-safe extraction,
ML-only formatting, is_odds_available truth table (ML-blind by
contract), config key/attr mismatch pin.
- test_dynamic_team_resolver.py: expansion/dedup/slicing, dropped
unknown-dynamic names (TOP_ substring hazard pinned), genuinely
shared class cache (second instance: zero HTTP), TTL expiry,
failure degradation without raising.
- test_display_helper.py (full): the fixed error/no-data renders,
combined scorebug top line, non-blank ticker with scroll_speed
no-op pin, composite upconversion, logo bleed positions, square
orientation pin.
- test_skin_runtime_cache.py: discovery-cache hit/invalidation
semantics (manifest mtime, .py edits pinned as non-invalidating),
sys.modules namespacing contract incl. bare-name restore and stdlib
shadowing, entry-module execute-once, API minor-version tolerance,
skin_matches_target table.
- test_sports_capabilities.py (extended): _draw_celebration_layout
executed for real (flash window, matrix-dims fallback, highlight
alternation, logo-failure isolation), _should_celebrate_for direct,
strict duration boundary, score_to_int edges, both-teams-score
precedence, expired-coalesce refire, disabled-win baseline
preservation, id-less prune.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* test: real schedule/dim coverage for DisplayController; fix two vacuous schedule tests
New test_display_controller_schedule.py drives _check_schedule and
_check_dim_schedule on a bare controller stub: same-day and
midnight-crossing windows with inclusive boundaries, global vs per-day vs
legacy-inferred modes (and dim's global-only default — no legacy
inference), per-day disabled days, invalid %H:%M fallbacks, unknown
timezone -> UTC, dim_brightness default 30, inactive-display short
circuit, and the _was_display_active/_was_dimmed transition flags.
test_display_controller.py's test_schedule_disabled and
test_active_hours patched config_service.get_config — which
_check_schedule never reads — so both asserted the init-default value
and could not fail. Rewritten on the test_inactive_hours pattern
(inject controller.config['schedule'], reset the minute gate, flip the
flag to the opposite state first so the assertion has teeth).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* ci: raise coverage floor to 48%
Measured 50% with the new suites in place (was 47% baseline when the
gate was introduced at 45); floor stays two points under measured.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* fix: address CodeQL alert and review findings
- config_manager: the "secrets list longer than config list" warning now
interpolates only config-side data (no key name or secrets-derived
values), resolving the CodeQL clear-text-logging alert.
- base_plugin: validate_config rejects bool display_duration, matching
get_display_duration (bool is an int subclass and would otherwise pass
as a positive number).
- config_helper: merge_configs deep-copies override values in the
non-recursive branch so mutating the merged result cannot reach back
into override_config.
- saved_repositories: saves are atomic (temp file + fsync + os.replace),
so a failed write can no longer truncate saved_repositories.json.
- tests: regression cases for each fix, plus a pin that whole-item
array secrets (key[] + key[].field both marked) strip to empty {}
skeletons — no secret values can reach config.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
---------
Co-authored-by: Claude <noreply@anthropic.com>
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""
|
|
Display Helper
|
|
|
|
Handles common display operations and layouts for LED matrix displays.
|
|
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
class DisplayHelper:
|
|
"""
|
|
Helper class for common display operations and layouts.
|
|
|
|
Provides functionality for:
|
|
- Creating base images and overlays
|
|
- Common layout patterns (scorebug, ticker, etc.)
|
|
- Image compositing and manipulation
|
|
- Display dimension utilities
|
|
"""
|
|
|
|
def __init__(self, display_width: int, display_height: int,
|
|
logger: Optional[logging.Logger] = None):
|
|
"""
|
|
Initialize the DisplayHelper.
|
|
|
|
Args:
|
|
display_width: Width of the LED matrix display
|
|
display_height: Height of the LED matrix display
|
|
logger: Optional logger instance
|
|
"""
|
|
self.display_width = display_width
|
|
self.display_height = display_height
|
|
self.logger = logger or logging.getLogger(__name__)
|
|
|
|
def create_base_image(self, background_color: Tuple[int, int, int] = (0, 0, 0),
|
|
mode: str = 'RGB') -> Image.Image:
|
|
"""
|
|
Create a base image for the display.
|
|
|
|
Args:
|
|
background_color: Background color (R, G, B)
|
|
mode: Image mode ('RGB', 'RGBA', etc.)
|
|
|
|
Returns:
|
|
PIL Image object
|
|
"""
|
|
return Image.new(mode, (self.display_width, self.display_height), background_color)
|
|
|
|
def create_overlay(self, background_color: Tuple[int, int, int, int] = (0, 0, 0, 0)) -> Image.Image:
|
|
"""
|
|
Create an overlay image for compositing.
|
|
|
|
Args:
|
|
background_color: Background color with alpha (R, G, B, A)
|
|
|
|
Returns:
|
|
PIL Image object with alpha channel
|
|
"""
|
|
return Image.new('RGBA', (self.display_width, self.display_height), background_color)
|
|
|
|
def composite_images(self, base_image: Image.Image, overlay_image: Image.Image) -> Image.Image:
|
|
"""
|
|
Composite overlay onto base image.
|
|
|
|
Args:
|
|
base_image: Base image (RGB or RGBA)
|
|
overlay_image: Overlay image (should be RGBA)
|
|
|
|
Returns:
|
|
Composited image
|
|
"""
|
|
if base_image.mode != 'RGBA':
|
|
base_image = base_image.convert('RGBA')
|
|
|
|
if overlay_image.mode != 'RGBA':
|
|
overlay_image = overlay_image.convert('RGBA')
|
|
|
|
return Image.alpha_composite(base_image, overlay_image)
|
|
|
|
def draw_scorebug_layout(self, game_data: Dict[str, Any],
|
|
fonts: Dict[str, ImageFont.ImageFont],
|
|
home_logo: Optional[Image.Image] = None,
|
|
away_logo: Optional[Image.Image] = None) -> Image.Image:
|
|
"""
|
|
Draw a standard scorebug layout for sports games.
|
|
|
|
Args:
|
|
game_data: Dictionary containing game information
|
|
fonts: Dictionary of loaded fonts
|
|
home_logo: Home team logo (optional)
|
|
away_logo: Away team logo (optional)
|
|
|
|
Returns:
|
|
PIL Image with scorebug layout
|
|
"""
|
|
# Create base image and overlay
|
|
main_img = self.create_base_image()
|
|
overlay = self.create_overlay()
|
|
draw = ImageDraw.Draw(overlay)
|
|
|
|
# Extract game data
|
|
home_score = str(game_data.get('home_score', '0'))
|
|
away_score = str(game_data.get('away_score', '0'))
|
|
home_abbr = game_data.get('home_abbr', 'HOME')
|
|
away_abbr = game_data.get('away_abbr', 'AWAY')
|
|
status_text = game_data.get('status_text', '')
|
|
period_text = game_data.get('period_text', '')
|
|
clock = game_data.get('clock', '')
|
|
|
|
# Draw logos if provided
|
|
if home_logo and away_logo:
|
|
self._draw_logos(main_img, home_logo, away_logo)
|
|
|
|
# Draw one combined top line (period/status/clock all share y=1 —
|
|
# drawing them separately overprinted each other).
|
|
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
|
if top_line:
|
|
self._draw_centered_text(draw, top_line,
|
|
fonts.get('time', fonts.get('status')),
|
|
y_position=1)
|
|
|
|
# Draw scores (center)
|
|
score_text = f"{away_score}-{home_score}"
|
|
self._draw_centered_text(draw, score_text, fonts.get('score'),
|
|
y_position=self.display_height // 2 - 3)
|
|
|
|
# Draw team abbreviations (bottom)
|
|
if away_abbr:
|
|
self._draw_text_with_outline(draw, away_abbr, (0, self.display_height - 12),
|
|
fonts.get('team'))
|
|
if home_abbr:
|
|
text_width = draw.textlength(home_abbr, font=fonts.get('team'))
|
|
self._draw_text_with_outline(draw, home_abbr,
|
|
(self.display_width - text_width, self.display_height - 12),
|
|
fonts.get('team'))
|
|
|
|
# Composite and return
|
|
final_img = self.composite_images(main_img, overlay)
|
|
return final_img.convert('RGB')
|
|
|
|
def draw_ticker_layout(self, text: str, font: ImageFont.ImageFont,
|
|
background_color: Tuple[int, int, int] = (0, 0, 0),
|
|
text_color: Tuple[int, int, int] = (255, 255, 255),
|
|
scroll_speed: int = 1) -> Image.Image:
|
|
"""
|
|
Draw a ticker/scrolling text layout.
|
|
|
|
Renders a single static frame with the text at the left edge; the
|
|
caller advances the scroll by re-rendering or shifting. The
|
|
scroll_speed parameter is accepted for API compatibility but does
|
|
not affect this frame. (Previously the text was drawn starting at
|
|
x=display_width — entirely off-canvas — so every frame was blank.)
|
|
|
|
Args:
|
|
text: Text to display
|
|
font: Font to use
|
|
background_color: Background color
|
|
text_color: Text color
|
|
scroll_speed: Accepted for compatibility; unused per-frame
|
|
|
|
Returns:
|
|
PIL Image with ticker layout
|
|
"""
|
|
img = self.create_base_image(background_color)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
|
font, fill=text_color)
|
|
|
|
return img
|
|
|
|
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
|
|
background_color: Tuple[int, int, int] = (0, 0, 0),
|
|
text_color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
|
|
"""
|
|
Draw centered text on the display.
|
|
|
|
Args:
|
|
text: Text to display
|
|
font: Font to use
|
|
background_color: Background color
|
|
text_color: Text color
|
|
|
|
Returns:
|
|
PIL Image with centered text
|
|
"""
|
|
img = self.create_base_image(background_color)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Calculate center position
|
|
text_width = draw.textlength(text, font=font)
|
|
text_height = 12 # Approximate height
|
|
x = (self.display_width - text_width) // 2
|
|
y = (self.display_height - text_height) // 2
|
|
|
|
# Draw text
|
|
self._draw_text_with_outline(draw, text, (x, y), font, fill=text_color)
|
|
|
|
return img
|
|
|
|
def draw_error_message(self, message: str = "Error") -> Image.Image:
|
|
"""
|
|
Draw a simple error message.
|
|
|
|
Args:
|
|
message: Error message to display
|
|
|
|
Returns:
|
|
PIL Image with error message
|
|
"""
|
|
# Dark red background, white text
|
|
font = ImageFont.load_default()
|
|
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
|
|
|
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
|
"""
|
|
Draw a no data message.
|
|
|
|
Args:
|
|
message: Message to display
|
|
|
|
Returns:
|
|
PIL Image with no data message
|
|
"""
|
|
font = ImageFont.load_default()
|
|
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
|
|
|
def get_display_dimensions(self) -> Tuple[int, int]:
|
|
"""
|
|
Get display dimensions.
|
|
|
|
Returns:
|
|
(width, height) tuple
|
|
"""
|
|
return (self.display_width, self.display_height)
|
|
|
|
def is_portrait(self) -> bool:
|
|
"""
|
|
Check if display is in portrait orientation.
|
|
|
|
Returns:
|
|
True if height > width
|
|
"""
|
|
return self.display_height > self.display_width
|
|
|
|
def is_landscape(self) -> bool:
|
|
"""
|
|
Check if display is in landscape orientation.
|
|
|
|
Returns:
|
|
True if width > height
|
|
"""
|
|
return self.display_width > self.display_height
|
|
|
|
def get_center_position(self) -> Tuple[int, int]:
|
|
"""
|
|
Get center position of the display.
|
|
|
|
Returns:
|
|
(x, y) center position
|
|
"""
|
|
return (self.display_width // 2, self.display_height // 2)
|
|
|
|
def _draw_logos(self, img: Image.Image, home_logo: Image.Image, away_logo: Image.Image) -> None:
|
|
"""Draw team logos on the image."""
|
|
center_y = self.display_height // 2
|
|
|
|
# Home logo (right side)
|
|
if home_logo:
|
|
home_x = self.display_width - home_logo.width + 10
|
|
home_y = center_y - (home_logo.height // 2)
|
|
img.paste(home_logo, (home_x, home_y), home_logo)
|
|
|
|
# Away logo (left side)
|
|
if away_logo:
|
|
away_x = -10
|
|
away_y = center_y - (away_logo.height // 2)
|
|
img.paste(away_logo, (away_x, away_y), away_logo)
|
|
|
|
def _draw_centered_text(self, draw: ImageDraw.ImageDraw, text: str,
|
|
font: ImageFont.ImageFont, y_position: int) -> None:
|
|
"""Draw centered text at specified y position."""
|
|
text_width = draw.textlength(text, font=font)
|
|
x = (self.display_width - text_width) // 2
|
|
self._draw_text_with_outline(draw, text, (x, y_position), font)
|
|
|
|
def _draw_text_with_outline(self, draw: ImageDraw.ImageDraw, text: str,
|
|
position: Tuple[int, int], font: ImageFont.ImageFont,
|
|
fill: Tuple[int, int, int] = (255, 255, 255),
|
|
outline_color: Tuple[int, int, int] = (0, 0, 0)) -> None:
|
|
"""Draw text with outline for better readability."""
|
|
x, y = position
|
|
|
|
# Draw outline
|
|
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
|
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
|
|
|
# Draw main text
|
|
draw.text((x, y), text, font=font, fill=fill)
|