From 4a1fd7464ad7875e16b8bff5c50814a9f831a87b Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:32:02 -0400 Subject: [PATCH 1/4] fix(errors): serve /api/v3/errors/* from the display service; add a Plugin errors panel (#614) * fix(errors): serve /api/v3/errors/* from the display service's aggregator The error aggregator is a per-process singleton and only the display service runs plugins, so only its aggregator records anything. The routes read the web process's own, empty one and always reported no errors. The display service now publishes a bounded snapshot of its aggregator to the shared cache (plugin_error_snapshot) from a daemon thread: at most once every 10 s and only when something changed, never raising into the caller. The routes read it and keep their response shapes, adding snapshot_available, generated_at and clear_pending; exception text has credentials redacted. POST /errors/clear writes a clear request (plugin_error_clear_request) that the display applies on its next 5 s tick via the new clear_before(), which keeps errors recorded after the cutoff and rebuilds the counts. Until the snapshot acknowledges the request, reads hide everything before the cutoff, so a snapshot written just before the click cannot bring errors back. Adds "all": true; cleared_count is null when only the display can know it. Co-Authored-By: Claude Opus 5.5 * feat(web): show plugin errors in the Logs tab A compact panel under the log viewer: per-plugin error counts, repeating errors (type, count, affected plugins, a sample message, last seen) and a Clear button, with empty states for "no errors" and "display service hasn't reported yet". Polls every 15 s while the tab is active; all text goes through escapeHtml. Co-Authored-By: Claude Opus 5.5 * docs: describe where plugin error reports come from and how clear works Co-Authored-By: Claude Opus 5.5 * fix(errors): redact the published snapshot before clipping it Keeping only a traceback's tail (or clipping a message) could cut an `api_key=` marker off while keeping the secret after it, and the web side's redaction would then have nothing to match. The display now redacts every free-text field of the snapshot first. The patterns move to a Flask-free src/redaction.py so the display service can use them; redact_text in the web error handler uses the same function, unchanged in behaviour. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 17 + docs/PLUGIN_ERROR_HANDLING.md | 18 +- docs/REST_API_REFERENCE.md | 84 +++- src/display_controller.py | 3 + src/error_aggregator.py | 470 +++++++++++++++++- src/redaction.py | 49 ++ src/web_interface/error_handler.py | 40 +- test/test_error_snapshot_cross_process.py | 443 +++++++++++++++++ web_interface/blueprints/api_v3/misc.py | 133 +++-- web_interface/templates/v3/partials/logs.html | 149 ++++++ 10 files changed, 1327 insertions(+), 79 deletions(-) create mode 100644 src/redaction.py create mode 100644 test/test_error_snapshot_cross_process.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2d3927..72f9cbb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,23 @@ floor on the release that ships them): longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a `br` response could not be decoded); requests picks the encodings. +### Plugin error reporting + +- `/api/v3/errors/summary` and `/api/v3/errors/plugin/` report the errors + the display service recorded. They used to read the web process's own error + aggregator, which never records anything, so they always answered "no + errors". The display service now publishes a bounded snapshot to the shared + cache (`plugin_error_snapshot`, at most every 10 seconds and only on change; + `src/error_aggregator.py`, started from `DisplayController.__init__`). + Responses keep their shape and add `snapshot_available`, `generated_at` and + `clear_pending`; exception text has credentials redacted. +- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`) + the display service applies within about 5 seconds; reads hide the cleared + errors at once. It accepts `"all": true`, and `cleared_count` can be `null` + when the count is only known to the display service. +- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating + errors and a Clear button. + ## 3.5.0 New modules a plugin may import via `src.*` (floor on 3.5.0): diff --git a/docs/PLUGIN_ERROR_HANDLING.md b/docs/PLUGIN_ERROR_HANDLING.md index a697dde8..16f02a09 100644 --- a/docs/PLUGIN_ERROR_HANDLING.md +++ b/docs/PLUGIN_ERROR_HANDLING.md @@ -146,7 +146,10 @@ def display(self, force_clear: bool = False) -> bool: ## Error Aggregation -LEDMatrix automatically tracks plugin errors. Access error data via the API: +LEDMatrix automatically tracks plugin errors: every exception or timeout from +a plugin's `update()` or `display()` is recorded by the display service, +which runs the plugins. See them in the web interface under **Logs → Plugin +errors**, or through the API: ```bash # Get error summary @@ -155,10 +158,21 @@ curl http://localhost:5000/api/v3/errors/summary # Get plugin-specific health curl http://localhost:5000/api/v3/errors/plugin/my-plugin -# Clear old errors +# Clear errors older than 24 hours (the default), or all of them curl -X POST http://localhost:5000/api/v3/errors/clear +curl -X POST -H 'Content-Type: application/json' -d '{"all": true}' \ + http://localhost:5000/api/v3/errors/clear ``` +The web interface is a separate process, so it reads a snapshot the display +service writes to the shared cache directory (`plugin_error_snapshot`): at most +every 10 seconds, and only when something changed. Expect the numbers to lag +by up to about 15 seconds, and to start from zero when the display service +restarts. `snapshot_available` is `false` until the display service has +reported. A clear is a request the display service applies within about 5 +seconds; the API hides the cleared errors immediately. Details and response +shapes: [REST API reference](REST_API_REFERENCE.md#error-tracking). + ### Error Patterns When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues. diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 26d2bb4d..f651a222 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -1815,25 +1815,73 @@ The last 100 journal lines for `ledmatrix.service` and ## Error tracking +Plugin errors are recorded by the display service (`ledmatrix.service`), +which runs the plugins. It publishes a snapshot to the shared cache directory +(`plugin_error_snapshot`) at most every 10 seconds, and only when something +changed, so these endpoints lag the display by up to about 15 seconds. The +counts cover the display service's current run: they start at zero when it +restarts. Error messages and stack traces have credentials redacted, and +messages, traces and context values are truncated in the snapshot. + +Every response below adds three fields to the shape it always had: + +| Field | Meaning | +|---|---| +| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. | +| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. | +| `clear_pending` | A clear has been requested and the display service has not applied it yet. | + ### Get Error Summary **GET** `/api/v3/errors/summary` -Aggregated counts, detected patterns and recent errors across plugins and -core components. +Aggregated counts, detected patterns and recent errors (the last 20). + +```json +{ + "status": "success", + "data": { + "session_start": "2026-09-23T09:40:02.118000", + "total_errors": 13, + "error_rate_per_hour": 41.2, + "error_counts_by_type": {"ConnectionError": 12, "ValueError": 1}, + "plugin_error_counts": {"weather": {"ConnectionError": 12}, "stocks": {"ValueError": 1}}, + "active_patterns": { + "ConnectionError": { + "error_type": "ConnectionError", "count": 12, + "first_seen": "2026-09-23T09:41:10.500000", "last_seen": "2026-09-23T09:58:36.020000", + "affected_plugins": ["weather"], "sample_messages": ["Read timed out."], + "severity": "error" + } + }, + "recent_errors": [ + {"error_type": "ValueError", "message": "could not parse price", + "timestamp": "2026-09-23T09:58:36.100000", "context": {}, + "plugin_id": "stocks", "operation": "update", "stack_trace": "Traceback ..."} + ], + "generated_at": "2026-09-23T09:58:40.000000", + "snapshot_available": true, + "clear_pending": false + }, + "message": "Error summary retrieved" +} +``` ### Get Plugin Errors **GET** `/api/v3/errors/plugin/` -Error health and statistics for one plugin. +Error health and statistics for one plugin: `plugin_id`, `status` +(`healthy`, `degraded` or `unhealthy`), `total_errors`, `error_types`, +`recent_error_count`, `last_error` (a `recent_errors` entry or `null`), plus +the three fields above. A plugin with no recorded errors is `healthy`. ### Clear Errors **POST** `/api/v3/errors/clear` -Clear error records older than `max_age_hours` (default 24, 1-8760). -Returns `data.cleared_count`. +Clear error records older than `max_age_hours` (default 24, 1-8760), or every +error with `"all": true` (`max_age_hours` is then ignored). ```json { @@ -1841,6 +1889,32 @@ Returns `data.cleared_count`. } ``` +The clear is asynchronous. The web interface records a request +(`plugin_error_clear_request` in the shared cache), and the display service +applies it within about 5 seconds, rebuilding its counts from the errors it +keeps and republishing. Reads hide the cleared errors from the moment the +request is recorded. Until the display service applies an age-based clear, +`recent_errors` and `active_patterns` are already filtered but the counts +are the old ones, and `clear_pending` is `true`. + +```json +{ + "status": "success", + "data": { + "cleared_count": 13, + "clear_requested": true, + "request_id": "5f0c1e...", + "cutoff": "2026-09-23T09:59:02.310000" + }, + "message": "Clear of all errors requested; the display service applies it within about 5 seconds" +} +``` + +`cleared_count` is how many of the reported errors the clear hides. It is +`null` when that cannot be known before the display service applies it (an +age-based clear over more errors than the report lists). A request that +could not be written to the shared cache answers `500`. + --- ## Health and Status diff --git a/src/display_controller.py b/src/display_controller.py index 1124f01b..1385cb82 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -95,6 +95,9 @@ class DisplayController: self.config_manager = config_manager # Keep for backward compatibility self.config = self.config_service.get_config() self.cache_manager = CacheManager() + # The web interface's /api/v3/errors/* read what this publishes. + from src.error_aggregator import start_error_snapshot_publisher + start_error_snapshot_publisher(self.cache_manager) logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload) # Validate startup configuration diff --git a/src/error_aggregator.py b/src/error_aggregator.py index 42eb65c7..55fc7b1e 100644 --- a/src/error_aggregator.py +++ b/src/error_aggregator.py @@ -9,17 +9,21 @@ This is a local-only implementation with no external dependencies. Errors are stored in memory with optional JSON export. """ +import math import threading +import time import traceback import json +import uuid from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path -from typing import Dict, List, Optional, Any, Callable +from typing import Dict, List, Optional, Any, Callable, Tuple import logging from src.exceptions import LEDMatrixError +from src.redaction import redact_credentials @dataclass @@ -115,6 +119,10 @@ class ErrorAggregator: # Track session start for relative timing self._session_start = datetime.now() + # Bumped on every change, so a publisher can tell "nothing new since + # the last snapshot" without comparing snapshots. + self._version = 0 + def record_error( self, error: Exception, @@ -161,6 +169,7 @@ class ErrorAggregator: self._error_counts[error_type] += 1 if plugin_id: self._plugin_error_counts[plugin_id][error_type] += 1 + self._version += 1 # Check for patterns self._detect_pattern(record) @@ -331,6 +340,77 @@ class ErrorAggregator: return cleared + @property + def version(self) -> int: + """Changes whenever the recorded errors do (see ErrorSnapshotPublisher).""" + return self._version + + def clear_before(self, cutoff: datetime) -> int: + """Forget every error recorded at or before ``cutoff``. + + Unlike clear_old_records, this also resets what the summary reports: + the per-type and per-plugin counts are rebuilt from the records that + remain, and detected patterns that began before the cutoff are dropped + (one that is still happening is detected again on its next + occurrence). Errors recorded after the + cutoff are kept, so a clear that is applied a few seconds after it was + requested does not swallow what happened in between. + + Returns: + Number of records removed + """ + with self._lock: + kept = [r for r in self._records if r.timestamp > cutoff] + cleared = len(self._records) - len(kept) + self._records = kept + self._error_counts = defaultdict(int) + self._plugin_error_counts = defaultdict(lambda: defaultdict(int)) + for r in kept: + self._error_counts[r.error_type] += 1 + if r.plugin_id: + self._plugin_error_counts[r.plugin_id][r.error_type] += 1 + # A pattern that began after the cutoff is made only of kept + # errors; any other would carry cleared ones in its count. + self._patterns = {k: p for k, p in self._patterns.items() if p.first_seen > cutoff} + self._version += 1 + return cleared + + def build_snapshot(self) -> Dict[str, Any]: + """A bounded, JSON-safe copy of the summary for another process. + + The shape of get_error_summary() plus ``generated_at`` and + ``plugin_health`` (get_plugin_health() for every plugin with errors). + Messages, stack traces and context are clipped so that one plugin + raising a huge exception cannot make the snapshot large. + """ + with self._lock: + summary = self.get_error_summary() + summary["generated_at"] = datetime.now().isoformat() + summary["recent_errors"] = [ + _compact_record(r) for r in summary["recent_errors"][-_SNAPSHOT_RECENT_ERRORS:] + ] + patterns = {} + for key, pattern in list(summary["active_patterns"].items())[:_SNAPSHOT_MAX_PATTERNS]: + pattern = dict(pattern) + pattern["affected_plugins"] = [ + _clip(p, _SNAPSHOT_ID_CHARS) for p in pattern.get("affected_plugins", []) + ][:_SNAPSHOT_MAX_AFFECTED_PLUGINS] + pattern["sample_messages"] = [ + _redacted_clip(m, _SNAPSHOT_SAMPLE_CHARS) for m in pattern.get("sample_messages", []) + ][:3] + patterns[key] = pattern + summary["active_patterns"] = patterns + health = {} + for plugin_id in summary["plugin_error_counts"]: + entry = self.get_plugin_health(plugin_id) + if entry["last_error"] is not None: + entry["last_error"] = _compact_record(entry["last_error"]) + health[plugin_id] = entry + summary["plugin_health"] = health + # Round-trip so a context value the cache's encoder cannot handle is + # turned into a string here rather than failing the write. + return json.loads(json.dumps(summary, default=str)) + def export_to_file(self, filepath: Path) -> None: """ Export error data to JSON file. @@ -419,3 +499,391 @@ def record_error( plugin_id=plugin_id, operation=operation ) + + +# --------------------------------------------------------------------------- +# Sharing the display service's errors with the web interface +# --------------------------------------------------------------------------- +# +# The two services are separate processes, so each has its own aggregator, +# and only the display service's ever records anything (plugin_executor runs +# the plugins there). The web interface therefore reads a snapshot the display +# service publishes to the shared cache directory -- the same channel, and the +# same file permissions, as display_current_state and plugin_metrics:*: files +# are 0660 and carry the cache directory's group, so root writes and the web +# user reads, and the other way round for the clear request. +# +# ERROR_SNAPSHOT_KEY written by the display service only +# ERROR_CLEAR_REQUEST_KEY written by the web interface only +# +# A clear is asynchronous: the web interface records a request, and the +# display service applies it (clear_before) on its next tick and republishes. +# Until it has, the web interface hides whatever the snapshot shows from +# before the cutoff, so a clear takes effect for readers immediately and a +# snapshot published just before the request cannot bring old errors back. +# The web interface never writes the snapshot itself: two writers would race, +# and a snapshot owned by the web user is one more file root's write has to +# replace. + +ERROR_SNAPSHOT_KEY = "plugin_error_snapshot" +ERROR_CLEAR_REQUEST_KEY = "plugin_error_clear_request" + +#: Shortest gap between two snapshot writes, in seconds. A plugin failing in +#: a tight loop changes the aggregator many times a second; the snapshot is +#: rewritten at most this often, and only when something changed. +SNAPSHOT_MIN_INTERVAL = 10.0 + +#: How often the display service checks for changes and clear requests. A +#: check is an in-memory comparison plus reading one small file. +SNAPSHOT_TICK_INTERVAL = 5.0 + +_SNAPSHOT_RECENT_ERRORS = 20 +_SNAPSHOT_MAX_PATTERNS = 50 +_SNAPSHOT_MAX_AFFECTED_PLUGINS = 50 +_SNAPSHOT_MESSAGE_CHARS = 300 +_SNAPSHOT_SAMPLE_CHARS = 200 +_SNAPSHOT_TRACE_CHARS = 1200 +_SNAPSHOT_ID_CHARS = 100 +_SNAPSHOT_CONTEXT_KEYS = 20 +_SNAPSHOT_CONTEXT_VALUE_CHARS = 200 + +#: Fields of get_error_summary(), which is what /errors/summary has always +#: returned. The snapshot's extra bookkeeping stays out of that response. +_SUMMARY_FIELDS = ( + "session_start", "total_errors", "error_rate_per_hour", + "error_counts_by_type", "plugin_error_counts", "active_patterns", + "recent_errors", +) + +_snapshot_logger = logging.getLogger(__name__ + ".snapshot") + + +def _clip(value: Any, limit: int) -> str: + text = value if isinstance(value, str) else str(value) + return text if len(text) <= limit else text[:limit - 3] + "..." + + +def _redacted_clip(value: Any, limit: int) -> str: + """Redact, then clip. Clipping first could cut a ``token=`` marker off + while keeping the secret after it, and the web side's redaction would then + have nothing to match.""" + return _clip(redact_credentials(value if isinstance(value, str) else str(value)), limit) + + +def _compact_record(record: Dict[str, Any]) -> Dict[str, Any]: + """An ErrorRecord dict with every free-text field redacted and bounded.""" + compact = dict(record) + compact["message"] = _redacted_clip(record.get("message") or "", _SNAPSHOT_MESSAGE_CHARS) + if record.get("plugin_id") is not None: + compact["plugin_id"] = _clip(record["plugin_id"], _SNAPSHOT_ID_CHARS) + trace = record.get("stack_trace") + if isinstance(trace, str): + trace = redact_credentials(trace) + if len(trace) > _SNAPSHOT_TRACE_CHARS: + # The end of a traceback is the part that says what went wrong. + trace = "..." + trace[-(_SNAPSHOT_TRACE_CHARS - 3):] + compact["stack_trace"] = trace + context = record.get("context") + if isinstance(context, dict): + compact["context"] = { + _clip(k, _SNAPSHOT_ID_CHARS): ( + v if v is None or isinstance(v, (bool, int, float)) + else _redacted_clip(v, _SNAPSHOT_CONTEXT_VALUE_CHARS) + ) + for k, v in list(context.items())[:_SNAPSHOT_CONTEXT_KEYS] + } + else: + compact["context"] = {} + return compact + + +class ErrorSnapshotPublisher: + """Publishes the display service's aggregator to the shared cache. + + Runs in the display service only. tick() is the whole job; start() just + calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which + also means errors recorded while a write was being throttled still reach + the cache once the interval has passed, and a clear request is applied + even when no new error arrives to trigger a publish. + + Nothing here raises: a failure to read or write the cache is logged at + debug and retried on a later tick. + """ + + def __init__(self, cache_manager: Any, aggregator: Optional[ErrorAggregator] = None, + min_interval: float = SNAPSHOT_MIN_INTERVAL, + clock: Callable[[], float] = time.monotonic) -> None: + self.cache_manager = cache_manager + self.aggregator = aggregator or get_error_aggregator() + self.min_interval = min_interval + self._clock = clock + # None forces a first publish, which replaces a snapshot left behind + # by a previous run of the service with this run's (empty) one. + self._published_version: Optional[int] = None + self._last_attempt: Optional[float] = None + self._applied_clear_id: Optional[str] = None + self._tick_lock = threading.Lock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def _apply_clear_request(self) -> bool: + """Honour a clear request we have not applied yet. True if one was.""" + request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0) + if not isinstance(request, dict): + return False + request_id = request.get("request_id") + if not isinstance(request_id, str) or not request_id or request_id == self._applied_clear_id: + return False + try: + cutoff = float(request.get("cutoff")) + except (TypeError, ValueError): + cutoff = float("nan") + if math.isfinite(cutoff): + cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff)) + _snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)", + cleared, request_id) + # A malformed request is acknowledged too, so it is not retried forever. + self._applied_clear_id = request_id + return True + + def tick(self) -> bool: + """Apply a pending clear and publish if due. True if a snapshot was written.""" + with self._tick_lock: + try: + cleared = self._apply_clear_request() + version = self.aggregator.version + now = self._clock() + if not cleared: + if version == self._published_version: + return False + if (self._last_attempt is not None + and now - self._last_attempt < self.min_interval): + return False + # Stamp the attempt before writing: a cache that keeps failing + # is retried at the throttled rate, not on every tick. + self._last_attempt = now + snapshot = self.aggregator.build_snapshot() + snapshot["applied_clear_id"] = self._applied_clear_id + self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot) + self._published_version = version + return True + except Exception as err: # never let reporting break the display + _snapshot_logger.debug("Could not publish the plugin error snapshot: %s", + err, exc_info=True) + return False + + def start(self, interval: float = SNAPSHOT_TICK_INTERVAL) -> None: + """Tick from a daemon thread until stop(). A no-op while running.""" + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + + def run() -> None: + self.tick() + while not self._stop.wait(interval): + self.tick() + + self._thread = threading.Thread(target=run, name="error-snapshot-publisher", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2) + self._thread = None + + +_snapshot_publisher: Optional[ErrorSnapshotPublisher] = None +_snapshot_publisher_lock = threading.Lock() + + +def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshotPublisher]: + """Start publishing this process's errors for the web interface. + + Call from the display service only: whichever process calls it becomes + the source of /api/v3/errors/*. Idempotent; never raises. + """ + global _snapshot_publisher + try: + with _snapshot_publisher_lock: + if _snapshot_publisher is None: + _snapshot_publisher = ErrorSnapshotPublisher(cache_manager) + else: + _snapshot_publisher.cache_manager = cache_manager + _snapshot_publisher.start() + return _snapshot_publisher + except Exception as err: + _snapshot_logger.warning("Plugin error reporting to the web interface is unavailable: %s", err) + return None + + +# --- Reading side (web interface) ------------------------------------------- + +def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """The display service's latest snapshot and the latest clear request. + + memory_ttl=0: both keys are written by the other process, so only the + file is current. + """ + snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0) + clear_request = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0) + return (snapshot if isinstance(snapshot, dict) else None, + clear_request if isinstance(clear_request, dict) else None) + + +def _epoch(iso: Any) -> Optional[float]: + """Seconds since the epoch for an aggregator timestamp (local, naive).""" + if not isinstance(iso, str): + return None + try: + return datetime.fromisoformat(iso).timestamp() + except (ValueError, OverflowError, OSError): + return None + + +def _pending_cutoff(snapshot: Optional[Dict[str, Any]], + clear_request: Optional[Dict[str, Any]]) -> Optional[float]: + """The cutoff of a clear the snapshot has not applied yet, if any.""" + if not clear_request: + return None + request_id = clear_request.get("request_id") + if not request_id: + return None + if snapshot is not None and snapshot.get("applied_clear_id") == request_id: + return None + try: + cutoff = float(clear_request.get("cutoff")) + except (TypeError, ValueError): + return None + return cutoff if math.isfinite(cutoff) else None + + +def _is_after(item: Any, field_name: str, cutoff: float) -> bool: + when = _epoch(item.get(field_name)) if isinstance(item, dict) else None + return when is not None and when > cutoff + + +def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]: + return { + "session_start": snapshot.get("session_start") if snapshot else None, + "total_errors": 0, + "error_rate_per_hour": 0.0, + "error_counts_by_type": {}, + "plugin_error_counts": {}, + "active_patterns": {}, + "recent_errors": [], + } + + +def error_summary_from_report(snapshot: Optional[Dict[str, Any]], + clear_request: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """The /errors/summary payload: get_error_summary()'s shape plus + ``generated_at``, ``snapshot_available`` and ``clear_pending``.""" + cutoff = _pending_cutoff(snapshot, clear_request) + summary = _empty_summary(snapshot) + if snapshot is not None: + for name, default in summary.items(): + value = snapshot.get(name) + if isinstance(value, type(default)) or ( + isinstance(default, float) and isinstance(value, int)) or ( + name == "session_start" and isinstance(value, str)): + summary[name] = value + if cutoff is not None: + recent = summary["recent_errors"] + newest = _epoch(recent[-1].get("timestamp")) if recent and isinstance(recent[-1], dict) else None + if newest is None or newest <= cutoff: + # Everything the display has reported predates the clear. + summary = _empty_summary(snapshot) + else: + # Only part of it does. The lists can be filtered exactly; the + # counts cannot, and stay as reported until the display + # applies the clear (clear_pending says so). + summary["recent_errors"] = [r for r in recent if _is_after(r, "timestamp", cutoff)] + summary["active_patterns"] = { + k: p for k, p in summary["active_patterns"].items() + if _is_after(p, "last_seen", cutoff) + } + summary["generated_at"] = snapshot.get("generated_at") if snapshot else None + summary["snapshot_available"] = snapshot is not None + summary["clear_pending"] = cutoff is not None + return summary + + +def plugin_health_from_report(snapshot: Optional[Dict[str, Any]], + clear_request: Optional[Dict[str, Any]], + plugin_id: str) -> Dict[str, Any]: + """The /errors/plugin/ payload: get_plugin_health()'s shape plus + ``generated_at``, ``snapshot_available`` and ``clear_pending``.""" + health: Dict[str, Any] = { + "plugin_id": plugin_id, + "status": "healthy", + "total_errors": 0, + "error_types": {}, + "recent_error_count": 0, + "last_error": None, + } + cutoff = _pending_cutoff(snapshot, clear_request) + table = snapshot.get("plugin_health") if snapshot else None + entry = table.get(plugin_id) if isinstance(table, dict) else None + if isinstance(entry, dict): + # last_error is the plugin's newest error: if even that predates a + # pending clear, so does everything else the display reported for it. + if cutoff is None or _is_after(entry.get("last_error"), "timestamp", cutoff): + for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"): + if name in entry: + health[name] = entry[name] + health["generated_at"] = snapshot.get("generated_at") if snapshot else None + health["snapshot_available"] = snapshot is not None + health["clear_pending"] = cutoff is not None + return health + + +def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]: + """How many of the reported errors a clear at ``cutoff`` hides, if known. + + Exact when every reported error predates the cutoff (always the case for + a clear of everything) or when the report lists every error; otherwise + only the display service knows, and None says so. + """ + total = summary["total_errors"] + times = [_epoch(r.get("timestamp")) if isinstance(r, dict) else None + for r in summary["recent_errors"]] + if total == 0 or (times and times[-1] is not None and times[-1] <= cutoff): + return total + if len(times) >= total and None not in times: + return sum(1 for t in times if t <= cutoff) + return None + + +def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]: + """Ask the display service to forget errors recorded at or before ``cutoff``. + + Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count`` + (see _count_cleared) and ``clear_requested``. Raises OSError when the + request did not reach the shared cache, since a cache without a usable + directory accepts set() and keeps nothing. + + A request the display has not applied yet is only ever widened: a later, + narrower one ("older than 24 hours" after "everything") overwriting it + would otherwise bring back the errors the first one hid. + """ + snapshot, clear_request = read_error_report(cache_manager) + pending = _pending_cutoff(snapshot, clear_request) + if pending is not None: + cutoff = max(cutoff, pending) + before = error_summary_from_report(snapshot, clear_request) + request = { + "request_id": uuid.uuid4().hex, + "cutoff": cutoff, + "requested_at": time.time(), + } + cache_manager.set(ERROR_CLEAR_REQUEST_KEY, request) + stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0) + if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]: + raise OSError("the clear request was not stored in the shared cache") + return { + "cleared_count": _count_cleared(before, cutoff), + "clear_requested": True, + "request_id": request["request_id"], + "cutoff": datetime.fromtimestamp(cutoff).isoformat(), + } diff --git a/src/redaction.py b/src/redaction.py new file mode 100644 index 00000000..b84bf1b1 --- /dev/null +++ b/src/redaction.py @@ -0,0 +1,49 @@ +"""Credential redaction for text that leaves the process that produced it. + +Kept free of Flask so the display service can redact what it publishes (see +src/error_aggregator.py) as well as the web interface what it returns. +""" + +import re + +# Credentials that turn up inside exception text. A requests error quotes the +# URL it failed on, and plugins that authenticate by query string put their key +# there, so echoing an exception verbatim can hand out an API key. Redact the +# value, keep the parameter name -- knowing *which* credential was involved is +# part of the diagnosis. +_REDACT_CREDENTIAL = re.compile( + r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|' + r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)', + re.IGNORECASE, +) + +# `Authorization: `. The scheme name is kept because it +# says which kind of credential failed; the credential goes. Any scheme +# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and +# whatever a plugin's API invents next are all credentials, and a list would +# silently leak the ones nobody thought of. Not covered by the generic pattern +# above, whose value part stops at whitespace and so would keep the credential +# once a space follows the scheme. +_REDACT_AUTH_HEADER = re.compile( + r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*' + r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept + r'([^\s,"\'<>}]+)', # the credential, redacted + re.IGNORECASE, +) + +# Credentials embedded in a URL: https://user:password@host. requests quotes +# the full URL in its exceptions, so this is a realistic leak. The username is +# kept -- it identifies which account failed without being the secret. +_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)', + re.IGNORECASE) + + +def redact_credentials(text: str) -> str: + """Replace credentials in ``text`` with ````; keep everything + else, including line breaks, so a stack trace stays readable.""" + text = text or '' + # Order matters: the URL and header forms are more specific than the + # generic key=value pattern, which would otherwise chew the scheme. + text = _REDACT_URL_USERINFO.sub(r'\1\3', text) + text = _REDACT_AUTH_HEADER.sub(r'\1', text) + return _REDACT_CREDENTIAL.sub(r'\1', text) diff --git a/src/web_interface/error_handler.py b/src/web_interface/error_handler.py index 509c5679..6554568c 100644 --- a/src/web_interface/error_handler.py +++ b/src/web_interface/error_handler.py @@ -4,7 +4,6 @@ Centralized error handling for web interface. Provides helpers for consistent error responses across API endpoints. """ -import re from typing import Any, Optional from flask import jsonify @@ -12,42 +11,12 @@ from src.web_interface.errors import ( WebInterfaceError, ErrorCode, ErrorCategory ) from src.logging_config import get_logger +from src.redaction import redact_credentials logger = get_logger(__name__) -# Credentials that turn up inside exception text. A requests error quotes the -# URL it failed on, and plugins that authenticate by query string put their key -# there, so echoing an exception verbatim can hand out an API key. Redact the -# value, keep the parameter name -- knowing *which* credential was involved is -# part of the diagnosis. -_REDACT_CREDENTIAL = re.compile( - r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|' - r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)', - re.IGNORECASE, -) - -# `Authorization: `. The scheme name is kept because it -# says which kind of credential failed; the credential goes. Any scheme -# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and -# whatever a plugin's API invents next are all credentials, and a list would -# silently leak the ones nobody thought of. Not covered by the generic pattern -# above, whose value part stops at whitespace and so would keep the credential -# once a space follows the scheme. -_REDACT_AUTH_HEADER = re.compile( - r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*' - r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept - r'([^\s,"\'<>}]+)', # the credential, redacted - re.IGNORECASE, -) - -# Credentials embedded in a URL: https://user:password@host. requests quotes -# the full URL in its exceptions, so this is a realistic leak. The username is -# kept -- it identifies which account failed without being the secret. -_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)', - re.IGNORECASE) - # Long enough for an errno string with a path, short enough not to dump a # parser's worth of context into a JSON field. _MAX_DETAIL_LENGTH = 400 @@ -95,12 +64,7 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str: Returns: A single line, credentials replaced, length capped. """ - text = text or '' - # Order matters: the URL and header forms are more specific than the - # generic key=value pattern, which would otherwise chew the scheme. - text = _REDACT_URL_USERINFO.sub(r'\1\3', text) - text = _REDACT_AUTH_HEADER.sub(r'\1', text) - text = _REDACT_CREDENTIAL.sub(r'\1', text) + text = redact_credentials(text) # Collapse newlines/tabs so the detail stays one line in a JSON field. text = ' '.join(text.split()) if len(text) > max_length: diff --git a/test/test_error_snapshot_cross_process.py b/test/test_error_snapshot_cross_process.py new file mode 100644 index 00000000..3cc48614 --- /dev/null +++ b/test/test_error_snapshot_cross_process.py @@ -0,0 +1,443 @@ +"""/api/v3/errors/* report the display service's errors, not the web's. + +The error aggregator is a per-process singleton, and only the display service +runs plugins, so only its aggregator ever records anything. The routes used to +read the web process's own aggregator and so always answered "no errors". + +Here the two services are two ErrorAggregator instances and two CacheManagers +over one temporary directory -- the same arrangement as the real services, +which share /var/cache/ledmatrix. The display side publishes through +ErrorSnapshotPublisher.tick(); the web side is the real blueprint. +""" +import json +import os +import stat +import sys +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.cache_manager import CacheManager # noqa: E402 +from src import error_aggregator as errors # noqa: E402 +from src.error_aggregator import ( # noqa: E402 + ERROR_CLEAR_REQUEST_KEY, ERROR_SNAPSHOT_KEY, ErrorAggregator, + ErrorSnapshotPublisher, +) +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + + +class FakeClock: + def __init__(self): + self.now = 1000.0 + + def __call__(self): + return self.now + + +def _fail(aggregator, plugin_id="p1", message="boom", exc=ValueError, **kwargs): + try: + raise exc(message) + except Exception as e: # noqa: BLE001 - recording is the point + return aggregator.record_error(e, plugin_id=plugin_id, operation="update", **kwargs) + + +@pytest.fixture +def shared_cache(tmp_path, monkeypatch): + """Two cache managers over one directory: the display's and the web's.""" + monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(tmp_path)) + display_cache, web_cache = CacheManager(), CacheManager() + yield display_cache, web_cache, tmp_path + display_cache.stop_cleanup_thread() + web_cache.stop_cleanup_thread() + + +@pytest.fixture +def display(shared_cache): + display_cache, _, _ = shared_cache + aggregator = ErrorAggregator() + clock = FakeClock() + publisher = ErrorSnapshotPublisher(display_cache, aggregator=aggregator, clock=clock) + return aggregator, publisher, clock + + +@pytest.fixture +def web(api_v3_module, api_v3_client, shared_cache): # noqa: F811 + _, web_cache, _ = shared_cache + api_v3_module.api_v3.cache_manager = web_cache + return api_v3_client + + +def _summary(client): + response = client.get("/api/v3/errors/summary") + assert response.status_code == 200, response.get_data(as_text=True) + return response.get_json()["data"] + + +# --- Display side ----------------------------------------------------------- + +class TestPublishing: + def test_errors_reach_the_shared_cache(self, display, shared_cache): + aggregator, publisher, _ = display + _, web_cache, _ = shared_cache + _fail(aggregator) + assert publisher.tick() is True + snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0) + assert snapshot["total_errors"] == 1 + assert snapshot["plugin_error_counts"] == {"p1": {"ValueError": 1}} + assert datetime.fromisoformat(snapshot["generated_at"]) + + def test_first_tick_publishes_even_with_no_errors(self, display, shared_cache): + # Replaces whatever a previous run of the display service left behind. + _, web_cache, _ = shared_cache + web_cache.set(ERROR_SNAPSHOT_KEY, {"total_errors": 99}) + _, publisher, _ = display + assert publisher.tick() is True + assert web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)["total_errors"] == 0 + + def test_nothing_is_written_when_nothing_changed(self, display, shared_cache): + aggregator, publisher, clock = display + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + clock.now += 3600 + assert publisher.tick() is False + publisher.cache_manager.set.assert_not_called() + + def test_a_tight_failure_loop_is_throttled(self, display): + aggregator, publisher, clock = display + _fail(aggregator) + assert publisher.tick() is True + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + for _ in range(50): + _fail(aggregator) + clock.now += 0.1 + publisher.tick() + publisher.cache_manager.set.assert_not_called() + # Once the interval has passed, the backlog is published without a + # new error having to arrive. + clock.now += errors.SNAPSHOT_MIN_INTERVAL + assert publisher.tick() is True + assert publisher.cache_manager.set.call_count == 1 + assert publisher.cache_manager.set.call_args[0][1]["total_errors"] == 51 + + def test_a_failing_cache_never_raises(self, display): + aggregator, publisher, clock = display + broken = MagicMock() + broken.get.side_effect = OSError("read-only file system") + broken.set.side_effect = OSError("read-only file system") + publisher.cache_manager = broken + _fail(aggregator) + assert publisher.tick() is False + broken.get.side_effect = None + broken.get.return_value = None + assert publisher.tick() is False # set still fails + # ...and retries at the throttled rate, not every tick. + calls = broken.set.call_count + clock.now += 1 + publisher.tick() + assert broken.set.call_count == calls + + def test_an_unserialisable_context_does_not_break_the_snapshot(self, display, shared_cache): + aggregator, publisher, _ = display + _fail(aggregator, context={"obj": object(), "n": 3}) + assert publisher.tick() is True + _, web_cache, _ = shared_cache + snap = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0) + assert snap["recent_errors"][0]["context"]["n"] == 3 + + def test_snapshot_stays_small(self, display): + aggregator, _, _ = display + for i in range(300): + _fail(aggregator, plugin_id=f"plugin-{i % 5}", message="x" * 20000, + context={f"k{j}": "v" * 5000 for j in range(50)}) + snapshot = aggregator.build_snapshot() + assert len(snapshot["recent_errors"]) == 20 + assert all(len(r["message"]) <= 300 for r in snapshot["recent_errors"]) + assert len(json.dumps(snapshot)) < 200_000 + + def test_start_publishes_from_a_background_thread(self, shared_cache): + display_cache, web_cache, _ = shared_cache + aggregator = ErrorAggregator() + _fail(aggregator) + publisher = ErrorSnapshotPublisher(display_cache, aggregator=aggregator) + try: + publisher.start(interval=0.05) + deadline = datetime.now() + timedelta(seconds=5) + snapshot = None + while snapshot is None and datetime.now() < deadline: + snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0) + assert snapshot and snapshot["total_errors"] == 1 + finally: + publisher.stop() + + def test_start_helper_never_raises(self, monkeypatch): + monkeypatch.setattr(errors, "_snapshot_publisher", None) + + def explode(*a, **k): + raise RuntimeError("no threads for you") + + monkeypatch.setattr(errors.ErrorSnapshotPublisher, "start", explode) + assert errors.start_error_snapshot_publisher(MagicMock()) is None + + def test_display_controller_starts_the_publisher(self): + # The display service is the only process that runs plugins, so it is + # the one that must publish; the web process must not. + # Read, not imported: display_controller needs rgbmatrix. + root = Path(__file__).parent.parent + controller = (root / "src" / "display_controller.py").read_text(encoding="utf-8") + init = controller.split(" def __init__(self):", 1)[1].split("\n def ", 1)[0] + assert "start_error_snapshot_publisher(self.cache_manager)" in init + web_dir = root / "web_interface" + for source in web_dir.rglob("*.py"): + assert "start_error_snapshot_publisher" not in source.read_text(encoding="utf-8"), source + + +class TestClearBefore: + def test_keeps_later_errors_and_rebuilds_counts(self): + aggregator = ErrorAggregator(pattern_threshold=2) + for _ in range(3): + _fail(aggregator, plugin_id="old") + for record in aggregator._records: + record.timestamp -= timedelta(hours=2) + for pattern in aggregator._patterns.values(): + pattern.first_seen -= timedelta(hours=2) + _fail(aggregator, plugin_id="new", exc=KeyError) + cleared = aggregator.clear_before(datetime.now() - timedelta(hours=1)) + assert cleared == 3 + summary = aggregator.get_error_summary() + assert summary["total_errors"] == 1 + assert summary["error_counts_by_type"] == {"KeyError": 1} + assert summary["plugin_error_counts"] == {"new": {"KeyError": 1}} + assert summary["active_patterns"] == {} + + def test_a_pattern_made_only_of_later_errors_survives(self): + # A clear applied a few seconds late must not drop a pattern that + # formed entirely after the cutoff. + aggregator = ErrorAggregator(pattern_threshold=2) + cutoff = datetime.now() - timedelta(seconds=1) + for _ in range(3): + _fail(aggregator) + aggregator.clear_before(cutoff) + assert list(aggregator.get_error_summary()["active_patterns"]) == ["ValueError"] + + def test_changes_the_version(self): + aggregator = ErrorAggregator() + v = aggregator.version + aggregator.clear_before(datetime.now()) + assert aggregator.version != v + + +# --- Web side --------------------------------------------------------------- + +class TestRoutes: + def test_no_snapshot_yet(self, web): + data = _summary(web) + assert data["snapshot_available"] is False + assert data["generated_at"] is None + assert data["total_errors"] == 0 + assert data["recent_errors"] == [] and data["active_patterns"] == {} + response = web.get("/api/v3/errors/summary") + assert "not reported" in response.get_json()["message"] + + def test_summary_is_the_display_services(self, web, display): + aggregator, publisher, _ = display + for _ in range(5): + _fail(aggregator, plugin_id="weather", message="HTTP 500") + publisher.tick() + data = _summary(web) + assert data["snapshot_available"] is True + assert data["clear_pending"] is False + assert data["total_errors"] == 5 + assert data["plugin_error_counts"] == {"weather": {"ValueError": 5}} + pattern = data["active_patterns"]["ValueError"] + assert pattern["affected_plugins"] == ["weather"] + assert pattern["sample_messages"] == ["HTTP 500"] + # The documented shape, plus only the documented additions. + assert set(data) == set(ErrorAggregator().get_error_summary()) | { + "generated_at", "snapshot_available", "clear_pending"} + + def test_web_process_aggregator_is_not_what_is_reported(self, web, display, monkeypatch): + # The original bug: the route read this process's own aggregator. + local = ErrorAggregator() + _fail(local, plugin_id="only-in-web") + monkeypatch.setattr(errors, "_error_aggregator", local) + aggregator, publisher, _ = display + _fail(aggregator, plugin_id="from-display") + publisher.tick() + assert list(_summary(web)["plugin_error_counts"]) == ["from-display"] + + def test_plugin_slice(self, web, display): + aggregator, publisher, _ = display + for _ in range(6): + _fail(aggregator, plugin_id="weather") + _fail(aggregator, plugin_id="clock", exc=KeyError) + publisher.tick() + data = web.get("/api/v3/errors/plugin/weather").get_json()["data"] + assert data["plugin_id"] == "weather" + assert data["status"] == "unhealthy" + assert data["total_errors"] == 6 + assert data["error_types"] == {"ValueError": 6} + assert data["last_error"]["plugin_id"] == "weather" + assert data["snapshot_available"] is True + healthy = web.get("/api/v3/errors/plugin/never-failed").get_json()["data"] + assert healthy["status"] == "healthy" and healthy["total_errors"] == 0 + assert healthy["last_error"] is None + + def test_credentials_in_exception_text_are_redacted(self, web, display): + aggregator, publisher, _ = display + for _ in range(5): + _fail(aggregator, message="GET https://api.example.com/?api_key=SEKRIT123 failed") + publisher.tick() + body = web.get("/api/v3/errors/summary").get_data(as_text=True) + body += web.get("/api/v3/errors/plugin/p1").get_data(as_text=True) + assert "SEKRIT123" not in body + assert "api_key=" in body + + def test_snapshot_is_redacted_before_it_is_clipped(self, display): + """The display redacts what it publishes, before clipping: keeping + only a traceback's tail could cut ``api_key=`` off and leave the key + itself, which the web side's redaction would then not recognise.""" + aggregator, _, _ = display + record = _fail(aggregator, message="GET /?token=MSGSECRET failed") + tail = errors._SNAPSHOT_TRACE_CHARS - 3 + filler = "x" * (tail - len("TRACESECRET ")) + record.stack_trace = "requests failed: api_key=TRACESECRET " + filler + assert record.stack_trace[-tail:].startswith("TRACESECRET") # marker falls outside + record.context = {"url": "https://h/?password=CTXSECRET"} + for _ in range(5): + _fail(aggregator, message="GET /?token=SAMPLESECRET failed") + published = json.dumps(aggregator.build_snapshot()) + for secret in ("MSGSECRET", "TRACESECRET", "CTXSECRET", "SAMPLESECRET"): + assert secret not in published, secret + + +class TestClear: + def test_clear_is_applied_by_the_display_and_republished(self, web, display): + aggregator, publisher, _ = display + _fail(aggregator) + _fail(aggregator) + publisher.tick() + response = web.post("/api/v3/errors/clear", json={"all": True}) + assert response.status_code == 200 + body = response.get_json()["data"] + assert body["clear_requested"] is True + assert body["cleared_count"] == 2 + # The display applies it on its next tick, throttle or not... + assert publisher.tick() is True + assert aggregator.get_error_summary()["total_errors"] == 0 + data = _summary(web) + assert data["total_errors"] == 0 and data["clear_pending"] is False + # ...and only once. + assert publisher.tick() is False + + def test_summary_hides_cleared_errors_before_the_display_applies_it(self, web, display): + aggregator, publisher, _ = display + for _ in range(5): + _fail(aggregator) + publisher.tick() + web.post("/api/v3/errors/clear", json={"all": True}) + # No display tick yet. + data = _summary(web) + assert data["clear_pending"] is True + assert data["total_errors"] == 0 + assert data["recent_errors"] == [] and data["active_patterns"] == {} + assert data["plugin_error_counts"] == {} + plugin = web.get("/api/v3/errors/plugin/p1").get_json()["data"] + assert plugin["status"] == "healthy" and plugin["total_errors"] == 0 + + def test_a_snapshot_written_just_before_the_clear_cannot_bring_errors_back( + self, web, display, shared_cache): + # The race: the display builds a snapshot, the user clicks Clear, and + # the display's write lands after the request. + aggregator, publisher, _ = display + display_cache, _, _ = shared_cache + for _ in range(3): + _fail(aggregator) + stale = aggregator.build_snapshot() + web.post("/api/v3/errors/clear", json={"all": True}) + stale["applied_clear_id"] = None + display_cache.set(ERROR_SNAPSHOT_KEY, stale) + assert _summary(web)["total_errors"] == 0 + + def test_errors_after_the_clear_are_kept(self, web, display): + aggregator, publisher, clock = display + _fail(aggregator, plugin_id="before") + publisher.tick() + web.post("/api/v3/errors/clear", json={"all": True}) + # An error lands after the request but before the display applies it. + for record in aggregator._records: + record.timestamp -= timedelta(seconds=5) + _fail(aggregator, plugin_id="after") + publisher.tick() + data = _summary(web) + assert data["plugin_error_counts"] == {"after": {"ValueError": 1}} + assert data["clear_pending"] is False + + def test_age_based_clear(self, web, display): + aggregator, publisher, _ = display + _fail(aggregator, plugin_id="old") + _fail(aggregator, plugin_id="old") + for record in aggregator._records: + record.timestamp -= timedelta(hours=3) + _fail(aggregator, plugin_id="new") + publisher.tick() + body = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"] + assert body["cleared_count"] == 2 + pending = _summary(web) + assert pending["clear_pending"] is True + assert [r["plugin_id"] for r in pending["recent_errors"]] == ["new"] + publisher.tick() + data = _summary(web) + assert data["plugin_error_counts"] == {"new": {"ValueError": 1}} + assert data["total_errors"] == 1 + + def test_a_narrower_clear_does_not_undo_a_pending_wider_one(self, web, display): + aggregator, publisher, _ = display + for _ in range(3): + _fail(aggregator) + publisher.tick() + web.post("/api/v3/errors/clear", json={"all": True}) + web.post("/api/v3/errors/clear", json={"max_age_hours": 24}) + assert _summary(web)["total_errors"] == 0 + publisher.tick() + assert aggregator.get_error_summary()["total_errors"] == 0 + + def test_default_body_still_means_older_than_24_hours(self, web, display): + aggregator, publisher, _ = display + _fail(aggregator) + publisher.tick() + response = web.post("/api/v3/errors/clear") + assert response.status_code == 200 + assert response.get_json()["data"]["cleared_count"] == 0 + publisher.tick() + assert _summary(web)["total_errors"] == 1 + + def test_validation_is_unchanged_but_all_skips_it(self, web): + assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400 + assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400 + assert web.post("/api/v3/errors/clear", + json={"all": True, "max_age_hours": "junk"}).status_code == 200 + + def test_a_request_that_did_not_reach_the_cache_is_an_error(self, web, api_v3_module): # noqa: F811 + cache = MagicMock() + cache.get.return_value = None + api_v3_module.api_v3.cache_manager = cache + response = web.post("/api/v3/errors/clear", json={"all": True}) + assert response.status_code == 500 + assert "clear request" in response.get_json()["message"] + + +@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt", + reason="POSIX file modes") +def test_both_files_are_group_readable(web, display, shared_cache): + aggregator, publisher, _ = display + _, _, directory = shared_cache + _fail(aggregator) + publisher.tick() + web.post("/api/v3/errors/clear", json={"all": True}) + for key in (ERROR_SNAPSHOT_KEY, ERROR_CLEAR_REQUEST_KEY): + mode = stat.S_IMODE(os.stat(directory / f"{key}.json").st_mode) + assert mode == 0o660, (key, oct(mode)) diff --git a/web_interface/blueprints/api_v3/misc.py b/web_interface/blueprints/api_v3/misc.py index baeddf59..ddcb8790 100644 --- a/web_interface/blueprints/api_v3/misc.py +++ b/web_interface/blueprints/api_v3/misc.py @@ -10,10 +10,11 @@ from web_interface.blueprints.api_v3 import ( _MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value, _get_display_service_status, _mqtt_bridge_service_state, _read_mqtt_bridge_config, api_v3, contextlib, describe_exception, - error_response, get_error_aggregator, json, jsonify, logger, os, request, + error_response, json, jsonify, logger, os, redact_text, request, subprocess, success_response, tempfile, ) from src.common.path_safety import safe_path_component +from src import error_aggregator as _errors import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these # as module attributes, and a value binding would not see the patch. @@ -326,17 +327,62 @@ def delete_cache_file(): except Exception as e: logger.error('Error in delete_cache_file', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +def _errors_cache(): + """The shared cache the display service publishes its errors to.""" + if not api_v3.cache_manager: + from src.cache_manager import CacheManager + api_v3.cache_manager = CacheManager() + return api_v3.cache_manager + + +def _redact_error_text(text, keep_lines=False): + """Credentials out of plugin exception text, which can quote a URL with + an API key in it. Stack traces keep their line breaks and indentation.""" + if not isinstance(text, str): + return text + if not keep_lines: + return redact_text(text, max_length=len(text) + 1) + return '\n'.join( + line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1) + for line in text.splitlines() + ) + + +def _redact_error_record(record): + if not isinstance(record, dict): + return record + record = dict(record) + record['message'] = _redact_error_text(record.get('message')) + record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True) + if isinstance(record.get('context'), dict): + record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()} + return record + + +def _read_errors(): + snapshot, clear_request = _errors.read_error_report(_errors_cache()) + return snapshot, clear_request + + @api_v3.route('/errors/summary', methods=['GET']) def get_error_summary(): """ Get summary of all errors for monitoring and debugging. - Returns error counts, detected patterns, and recent errors. + Returns error counts, detected patterns, and recent errors, as last + reported by the display service (which runs the plugins, so it is the + only process that records their errors). ``snapshot_available`` is false + until it has reported; ``generated_at`` says when it did. """ try: - aggregator = get_error_aggregator() - summary = aggregator.get_error_summary() - return success_response(data=summary, message="Error summary retrieved") + summary = _errors.error_summary_from_report(*_read_errors()) + summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']] + for pattern in summary['active_patterns'].values(): + if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list): + pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']] + message = ("Error summary retrieved" if summary['snapshot_available'] + else "The display service has not reported any errors yet") + return success_response(data=summary, message=message) except Exception as e: logger.error(f"Error getting error summary: {e}", exc_info=True) return error_response( @@ -352,11 +398,13 @@ def get_plugin_errors(plugin_id): Args: plugin_id: Plugin identifier - Returns health status and error statistics for the plugin. + Returns health status and error statistics for the plugin, from the + display service's last report (see get_error_summary). A plugin with no + recorded errors is "healthy". """ try: - aggregator = get_error_aggregator() - health = aggregator.get_plugin_health(plugin_id) + health = _errors.plugin_health_from_report(*_read_errors(), plugin_id) + health['last_error'] = _redact_error_record(health['last_error']) return success_response(data=health, message="Plugin health retrieved") except Exception as e: logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True) @@ -372,42 +420,61 @@ def clear_old_errors(): Request body (optional): max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year) + all: true clears every error recorded so far (max_age_hours ignored) + + The errors live in the display service, so this records a clear request + that it applies within a few seconds. Reads hide the cleared errors from + the moment the request is recorded. """ try: data = request.get_json(silent=True) or {} + clear_all = _coerce_to_bool(data.get('all')) raw_max_age = data.get('max_age_hours', 24) # Validate and coerce max_age_hours + max_age_hours = None + if not clear_all: + try: + max_age_hours = int(raw_max_age) + if max_age_hours < 1: + return error_response( + error_code=ErrorCode.INVALID_INPUT, + message="max_age_hours must be at least 1", + context={'provided_value': raw_max_age}, + status_code=400 + ) + if max_age_hours > 8760: # 1 year max + return error_response( + error_code=ErrorCode.INVALID_INPUT, + message="max_age_hours cannot exceed 8760 (1 year)", + context={'provided_value': raw_max_age}, + status_code=400 + ) + except (ValueError, TypeError, OverflowError): + return error_response( + error_code=ErrorCode.INVALID_INPUT, + message="max_age_hours must be a valid integer", + context={'provided_value': str(raw_max_age)}, + status_code=400 + ) + + now = _pkg.time.time() + cutoff = now if clear_all else now - max_age_hours * 3600 try: - max_age_hours = int(raw_max_age) - if max_age_hours < 1: - return error_response( - error_code=ErrorCode.INVALID_INPUT, - message="max_age_hours must be at least 1", - context={'provided_value': raw_max_age}, - status_code=400 - ) - if max_age_hours > 8760: # 1 year max - return error_response( - error_code=ErrorCode.INVALID_INPUT, - message="max_age_hours cannot exceed 8760 (1 year)", - context={'provided_value': raw_max_age}, - status_code=400 - ) - except (ValueError, TypeError, OverflowError): + result = _errors.request_error_clear(_errors_cache(), cutoff) + except OSError as e: + logger.error("Could not record an error clear request: %s", e) return error_response( - error_code=ErrorCode.INVALID_INPUT, - message="max_age_hours must be a valid integer", - context={'provided_value': str(raw_max_age)}, - status_code=400 + error_code=ErrorCode.SYSTEM_ERROR, + message="Could not record the clear request in the shared cache", + status_code=500 ) - aggregator = get_error_aggregator() - cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours) - + scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours" return success_response( - data={'cleared_count': cleared_count}, - message=f"Cleared {cleared_count} error records older than {max_age_hours} hours" + data=result, + message=(f"Clear of {scope} requested; the display service applies it " + f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds") ) except Exception as e: logger.error(f"Error clearing old errors: {e}", exc_info=True) diff --git a/web_interface/templates/v3/partials/logs.html b/web_interface/templates/v3/partials/logs.html index 3a23b8b9..81b3c4fb 100644 --- a/web_interface/templates/v3/partials/logs.html +++ b/web_interface/templates/v3/partials/logs.html @@ -104,6 +104,25 @@
Connected to log stream + + +
+
+
+

Plugin errors

+

Recorded by the display service

+
+
+ + +
+
+
Loading...
+
From 4fe3cdd906261d222dd038af66a89f95564aff79 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:33:12 -0400 Subject: [PATCH 2/4] fix(starlark): stop the root display service locking the web UI out of starlark-apps (#604) * fix(starlark): stop the root display service locking the web UI out Reported after a fresh install: installing an app from the Starlark tab failed with "install failed: Failed to install from repository", and so did uploading a .star file and installing from a GitHub directory. The reporter found the cause only by reading service logs, and fixed it with sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/starlark-apps starlark-apps is gitignored, so it is never checked out -- it is created lazily by whichever process reaches it first. Those processes run as different users. systemd/ledmatrix.service is User=root and constructs this plugin at startup, which is where _get_apps_directory() is called from; systemd/ledmatrix-web.service runs as the login user and is what actually installs apps. The documented first step is to install pixlet and reboot, so on a fresh machine the display service usually wins that race and mkdir() leaves the directory root-owned. The web process then fails in _install_star_file() on app_dir.mkdir(), which catches nothing, so PermissionError reaches the route's outer `except Exception` and becomes the generic message the user saw. All three install paths write to the same directory, which is why all three failed. The web user cannot repair this -- chown needs root. So root does it, on every startup, which also heals machines already broken by this without the owner having to find the chown themselves. It is a no-op when not root, when the platform has no POSIX ownership, and when the checkout genuinely belongs to root; a chown that fails warns rather than killing startup. Also made the failure legible if the handover is ever prevented: a PermissionError now names the directory, the automatic repair, and the manual chown, instead of a message that names neither path nor cause. Verified by mutation: dropping the handover call, chowning a genuinely root-owned checkout, and letting a non-root process chown each fail their own test. 121 starlark tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(starlark): address the review on the ownership repair Findings from the automated review of #604. Symlinks (CWE-59, the serious one). A root chown that follows links is a privilege-escalation primitive: anyone able to write in starlark-apps could point a link at a root-owned file and have the repair hand it over. Entries are now read with os.lstat, symlinks are skipped outright, and the chown passes follow_symlinks=False. Descendants are processed before the directory itself, so the container does not change hands while its contents are still being walked. install_app() caught PermissionError in its broad handler and returned False, which both routes report as a generic install failure -- the exact shape of the bug this PR exists to fix, since the caller could not tell "this app is broken" from "this process cannot write here". PermissionError is now re-raised; every other failure still returns False. The test fixtures skipped on bare Exception, which would have turned a syntax error or NameError in the plugin into a green run. They now skip only for a named absent dependency and re-raise anything else. Also fixed the _Stat stub that failed in CI but passed locally: it carried only st_uid/st_gid, and pathlib reads st_mode while walking. It now wraps the real stat result and overrides ownership alone. NOT taken: the CodeQL "information exposure through an exception" finding on the hint response. Dropping `details` would contradict this package's documented rule -- "if it returns 5xx, it says why" -- which test_no_api_v3_handler_discards_its_exception enforces with an allowance that may shrink and never grow. The Starlark routes are the ones that policy was written for: they answered 500 with no detail for three releases. describe_exception already redacts credentials and truncates. Keeping the detail is the deliberate trade-off, so the finding is declined rather than silently worked around. Verified on hdpi with the updated code: a symlink to /etc/shadow planted in starlark-apps was skipped while the directory was handed back, and /etc/shadow stayed root:shadow. Mutation-checked all three behaviours. The symlink test was vacuous on the first attempt -- the link already had the target owner, so it was skipped for the wrong reason and the mutation passed. It now forces the link to look like it needs handing over, and fails when the check is removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --------- Co-authored-by: Claude Opus 5 (1M context) --- plugin-repos/starlark-apps/manager.py | 75 +++- test/test_starlark_apps_dir_ownership.py | 366 ++++++++++++++++++++ web_interface/blueprints/api_v3/starlark.py | 43 ++- 3 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 test/test_starlark_apps_dir_ownership.py diff --git a/plugin-repos/starlark-apps/manager.py b/plugin-repos/starlark-apps/manager.py index 5a810079..e078ec55 100644 --- a/plugin-repos/starlark-apps/manager.py +++ b/plugin-repos/starlark-apps/manager.py @@ -10,6 +10,7 @@ API Version: 1.0.0 import json import os import re +import stat import time import fcntl from pathlib import Path @@ -457,12 +458,77 @@ class StarlarkAppsPlugin(BasePlugin): apps_dir = project_root / "starlark-apps" except Exception: # Fallback to current working directory - apps_dir = Path.cwd() / "starlark-apps" + project_root = Path.cwd() + apps_dir = project_root / "starlark-apps" # Create directory if it doesn't exist apps_dir.mkdir(parents=True, exist_ok=True) + self._hand_apps_dir_to_checkout_owner(apps_dir, project_root) return apps_dir + def _hand_apps_dir_to_checkout_owner(self, apps_dir: Path, project_root: Path) -> None: + """Give the apps directory to whoever owns the checkout. + + This directory is not in the repository, so it is created lazily by + whichever process reaches it first -- and the two that do run as + different users. The display service is `User=root` + (systemd/ledmatrix.service) and instantiates this plugin at startup, + which is where `_get_apps_directory` is called from. The web interface + is `User=` (systemd/ledmatrix-web.service) and is what + actually installs apps. + + On a fresh install the display service usually wins that race -- the + documented first step is to install pixlet and reboot -- so the + directory lands root-owned, and every subsequent install from the web + UI fails on PermissionError. The user sees only "Failed to install + from repository", with nothing pointing at ownership. + + The web user cannot repair this; it lacks permission to chown. Root + can, so root does it here, on every startup. That also heals installs + already broken by this, without the user having to find the chown. + """ + geteuid = getattr(os, "geteuid", None) + chown = getattr(os, "chown", None) + if geteuid is None or chown is None or geteuid() != 0: + # Not root, or not a platform with POSIX ownership. If the + # directory is wrong we cannot fix it, and must not pretend to. + return + try: + owner = project_root.stat() + except OSError: + return + if owner.st_uid == 0: + # The checkout genuinely belongs to root, so root owning the apps + # directory is correct and there is nobody to hand it to. + return + + # Deepest first, with the directory itself last. Handing over the + # container before its contents would briefly let a local user rename + # entries underneath a repair that is still running. + descendants = sorted(apps_dir.rglob("*"), + key=lambda p: len(p.parts), reverse=True) + for path in (*descendants, apps_dir): + try: + st = os.lstat(path) + except OSError: + continue + if stat.S_ISLNK(st.st_mode): + # Never hand over a link's target. Anyone able to write in + # this directory could otherwise point a symlink at a + # root-owned file and have this give it away -- the whole + # point of the loop is that it runs as root. + continue + if st.st_uid == owner.st_uid and st.st_gid == owner.st_gid: + continue + try: + chown(path, owner.st_uid, owner.st_gid, follow_symlinks=False) + except OSError as e: + self.logger.warning( + "Could not hand %s to uid %s: %s -- installs from the web " + "interface will fail until this is chowned manually", + path, owner.st_uid, e, + ) + def _sanitize_app_id(self, app_id: str) -> str: """ Sanitize app_id into a safe slug for use in file paths. @@ -1011,6 +1077,13 @@ class StarlarkAppsPlugin(BasePlugin): self.logger.info(f"Installed Starlark app: {app_id} (sanitized: {safe_app_id})") return True + except PermissionError: + # Deliberately not folded into the False below. A False here is + # reported as a generic install failure, which is how the + # directory-ownership bug stayed invisible: the caller could not + # tell "this app is broken" from "this process cannot write here". + # The routes turn this into a message that names the fix. + raise except Exception as e: self.logger.error(f"Error installing app {app_id}: {e}") return False diff --git a/test/test_starlark_apps_dir_ownership.py b/test/test_starlark_apps_dir_ownership.py new file mode 100644 index 00000000..8d64e5a5 --- /dev/null +++ b/test/test_starlark_apps_dir_ownership.py @@ -0,0 +1,366 @@ +"""starlark-apps: the root display service must not lock the web UI out. + +Reported by a user after a fresh install: installing an app from the Starlark +tab failed with "install failed: Failed to install from repository", and so did +uploading a .star file and installing from a GitHub directory. The cause was +ownership, which the error named nowhere -- they found it only by reading the +service logs, and fixed it with + + sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/starlark-apps + +The starlark-apps directory is not in the repository, so it is created lazily +by whichever process reaches it first. Those processes run as different users: +systemd/ledmatrix.service is `User=root` and constructs this plugin at startup +(which is what calls _get_apps_directory), while systemd/ledmatrix-web.service +runs as the login user and is what actually installs apps. The documented +first step is to install pixlet and reboot, so on a fresh machine the display +service usually wins the race and the directory lands root-owned. + +The web user cannot repair that -- chown needs root. So root hands the +directory over itself, every startup, which also heals machines already broken +by this. +""" + +import importlib.util +import os +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps" + + +@pytest.fixture(scope="module") +def manager_module(): + if not PLUGIN_DIR.exists(): + pytest.skip("starlark-apps plugin is not checked out") + sys.path.insert(0, str(PLUGIN_DIR)) + injected_fcntl = "fcntl" not in sys.modules + if injected_fcntl: + stub = types.ModuleType("fcntl") + stub.LOCK_EX, stub.LOCK_UN = 2, 8 + stub.flock = lambda *a, **kw: None + sys.modules["fcntl"] = stub + try: + spec = importlib.util.spec_from_file_location( + "starlark_manager_ownership", PLUGIN_DIR / "manager.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + except ImportError as e: + # Only a genuinely absent dependency is a skip. A syntax error or a + # NameError in the plugin is a regression these tests exist to catch, + # and swallowing it here would turn a red suite green. + if any(dep in str(e) for dep in ("PIL", "Pillow", "pixlet", "frame_extractor")): + pytest.skip(f"starlark-apps optional dependency missing: {e}") + raise + finally: + sys.path.remove(str(PLUGIN_DIR)) + if injected_fcntl: + sys.modules.pop("fcntl", None) + + +def _plugin(manager_module): + """A manager with __init__ bypassed -- only ownership paths are tested.""" + cls = manager_module.StarlarkAppsPlugin + inst = cls.__new__(cls) + inst.logger = MagicMock() + return inst + + +class _Stat: + """A real stat_result with only the ownership fields overridden. + + Everything else is delegated to the genuine result. A stub carrying just + st_uid/st_gid passed locally but broke in CI, because pathlib itself reads + st_mode while walking the tree on some Python versions -- and the fields + it needs are an implementation detail, not something this test should be + asserting about. + """ + + def __init__(self, real, uid, gid): + self._real = real + self.st_uid = uid + self.st_gid = gid + + def __getattr__(self, name): + return getattr(self._real, name) + + +@pytest.fixture +def owned(monkeypatch): + """Let a test declare a fake uid/gid for specific paths. + + Real ownership cannot be faked without root, and these tests must run as + an ordinary user in CI. + """ + fake = {} + real_stat = Path.stat + real_lstat = os.lstat + + def patched_stat(self, *args, **kwargs): + st = real_stat(self, *args, **kwargs) + key = str(self) + return _Stat(st, *fake[key]) if key in fake else st + + def patched_lstat(path, *args, **kwargs): + st = real_lstat(path, *args, **kwargs) + key = str(path) + return _Stat(st, *fake[key]) if key in fake else st + + # Both, because the code reads the checkout owner through Path.stat and + # each entry it repairs through os.lstat -- lstat so a symlink reports + # itself rather than its target. + monkeypatch.setattr(Path, "stat", patched_stat) + monkeypatch.setattr(os, "lstat", patched_lstat) + return fake + + +@pytest.fixture +def as_root(monkeypatch): + """Run the handover as root, recording chowns instead of performing them.""" + calls = [] + monkeypatch.setattr("os.geteuid", lambda: 0) + monkeypatch.setattr( + "os.chown", + lambda p, uid, gid, **kw: calls.append((str(p), uid, gid))) + return calls + + +def _tree(tmp_path): + """A project root with an apps directory holding one installed app.""" + apps = tmp_path / "starlark-apps" + (apps / "analogclock").mkdir(parents=True) + (apps / "analogclock" / "analog_clock.star").write_text("# app") + (apps / "manifest.json").write_text("{}") + return apps + + +class TestRootHandsTheDirectoryOver: + def test_root_created_directory_is_given_to_the_checkout_owner( + self, manager_module, tmp_path, owned, as_root): + apps = _tree(tmp_path) + owned[str(tmp_path)] = (1000, 1000) # checkout belongs to the login user + owned[str(apps)] = (0, 0) # but root got there first + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + assert (str(apps), 1000, 1000) in as_root + + def test_contents_are_repaired_not_just_the_directory( + self, manager_module, tmp_path, owned, as_root): + """An install broken by this leaves root-owned files inside it too.""" + apps = _tree(tmp_path) + owned[str(tmp_path)] = (1000, 1000) + for p in (apps, apps / "analogclock", + apps / "analogclock" / "analog_clock.star", + apps / "manifest.json"): + owned[str(p)] = (0, 0) + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + chowned = {c[0] for c in as_root} + assert str(apps / "analogclock" / "analog_clock.star") in chowned + assert str(apps / "manifest.json") in chowned + + def test_nothing_is_touched_when_ownership_is_already_right( + self, manager_module, tmp_path, owned, as_root): + apps = _tree(tmp_path) + owned[str(tmp_path)] = (1000, 1000) + for p in (apps, apps / "analogclock", + apps / "analogclock" / "analog_clock.star", + apps / "manifest.json"): + owned[str(p)] = (1000, 1000) + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + assert as_root == [] + + +class TestItDoesNotOverreach: + def test_a_non_root_process_changes_nothing( + self, manager_module, tmp_path, owned, monkeypatch): + """The web service also calls this. It has no right to chown anything.""" + apps = _tree(tmp_path) + owned[str(tmp_path)] = (1000, 1000) + owned[str(apps)] = (0, 0) + calls = [] + monkeypatch.setattr("os.geteuid", lambda: 1000) + monkeypatch.setattr("os.chown", lambda p, u, g, **kw: calls.append(p)) + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + assert calls == [] + + def test_a_genuinely_root_owned_checkout_is_left_alone( + self, manager_module, tmp_path, owned, as_root): + """Installed as root on purpose: there is nobody to hand it to.""" + apps = _tree(tmp_path) + owned[str(tmp_path)] = (0, 0) + owned[str(apps)] = (0, 0) + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + assert as_root == [] + + def test_a_failed_chown_warns_and_does_not_raise( + self, manager_module, tmp_path, owned, monkeypatch): + """Startup must not die because one file could not be handed over.""" + apps = _tree(tmp_path) + owned[str(tmp_path)] = (1000, 1000) + owned[str(apps)] = (0, 0) + monkeypatch.setattr("os.geteuid", lambda: 0) + + def boom(*_a, **_kw): + raise OSError("read-only file system") + + monkeypatch.setattr("os.chown", boom) + plugin = _plugin(manager_module) + + plugin._hand_apps_dir_to_checkout_owner(apps, tmp_path) # must not raise + + assert plugin.logger.warning.called + + +class TestTheDirectoryGetterUsesIt: + def test_get_apps_directory_performs_the_handover( + self, manager_module, monkeypatch): + """Pins the wiring: creating the directory without handing it over is + exactly the bug. + + This calls the real getter, which resolves to the checkout's own + starlark-apps directory, so it removes the directory again when the + test was what created it. + """ + plugin = _plugin(manager_module) + seen = [] + monkeypatch.setattr( + type(plugin), "_hand_apps_dir_to_checkout_owner", + lambda self, apps, root: seen.append((apps, root))) + expected = PLUGIN_DIR.parent.parent / "starlark-apps" + pre_existing = expected.exists() + + try: + result = plugin._get_apps_directory() + + assert result == expected and result.exists() + assert seen and seen[0] == (expected, expected.parent) + finally: + if not pre_existing and expected.exists(): + try: + expected.rmdir() + except OSError: + pass + + +class TestTheErrorNamesTheCause: + """The reporter saw only "Failed to install from repository". + + That message names no path and no cause, so the only way to the answer was + reading the service logs. The handover above should stop the failure + happening at all; this makes the failure legible if it ever does. + """ + + @pytest.fixture(scope="class") + def hint(self): + try: + from web_interface.blueprints.api_v3.starlark import _ownership_hint + except ImportError as e: + # Same rule as above: absent Flask is a skip, a broken module is not. + if "flask" in str(e).lower(): + pytest.skip(f"Flask is not installed here: {e}") + raise + return _ownership_hint + + def test_a_permission_error_explains_itself(self, hint): + message = hint(PermissionError(13, "Permission denied")) + assert message + assert "chown" in message + assert "starlark-apps" in message + + def test_it_points_at_the_automatic_repair_first(self, hint): + """Restarting the display service is the fix that needs no root user + to understand it -- the manual chown is the fallback.""" + message = hint(PermissionError(13, "Permission denied")) + assert "systemctl restart ledmatrix" in message + assert message.index("systemctl") < message.index("chown") + + def test_other_failures_are_not_mislabelled(self, hint): + """A network error must not be reported as an ownership problem.""" + for err in (ValueError("bad json"), OSError(28, "No space left on device"), + TimeoutError("github timed out")): + assert hint(err) is None + + +class TestItWillNotBeTrickedIntoGivingAwayAFile: + """Root chowning a tree is a privilege-escalation primitive if it follows + links: anyone who can write in the directory could point one at a + root-owned file and have this hand it over.""" + + def test_a_symlink_is_never_followed(self, manager_module, tmp_path, owned, as_root): + apps = _tree(tmp_path) + target = tmp_path / "precious" + target.write_text("root-owned secret") + (apps / "evil").symlink_to(target) + owned[str(tmp_path)] = (1000, 1000) + owned[str(apps)] = (0, 0) + # The link must look like it NEEDS handing over, or it would be + # skipped for already having the right owner and this test would pass + # without ever exercising the symlink check. + owned[str(apps / "evil")] = (0, 0) + owned[str(target)] = (0, 0) + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + chowned = {c[0] for c in as_root} + assert str(target) not in chowned + assert str(apps / "evil") not in chowned + + def test_the_directory_is_handed_over_last(self, manager_module, tmp_path, owned, as_root): + """Its contents must be settled before the container changes hands.""" + apps = _tree(tmp_path) + owned[str(tmp_path)] = (1000, 1000) + for p in (apps, apps / "analogclock", + apps / "analogclock" / "analog_clock.star", + apps / "manifest.json"): + owned[str(p)] = (0, 0) + + _plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path) + + order = [c[0] for c in as_root] + assert order[-1] == str(apps) + + +class TestAPermissionFailureReachesTheCaller: + def test_install_app_does_not_swallow_permission_errors( + self, manager_module, tmp_path, monkeypatch): + """A False here reads as "this app is broken" and routes to a generic + message -- which is how the ownership bug stayed invisible.""" + plugin = _plugin(manager_module) + plugin.apps_dir = tmp_path + plugin.apps = {} + + def denied(self, *a, **kw): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "mkdir", denied) + + with pytest.raises(PermissionError): + plugin.install_app("analogclock", str(tmp_path / "x.star"), {}) + + def test_other_install_failures_still_return_false( + self, manager_module, tmp_path, monkeypatch): + """Only permission errors are promoted; the bool contract is intact.""" + plugin = _plugin(manager_module) + plugin.apps_dir = tmp_path + plugin.apps = {} + + def broken(self, *a, **kw): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(Path, "mkdir", broken) + + assert plugin.install_app("analogclock", str(tmp_path / "x.star"), {}) is False diff --git a/web_interface/blueprints/api_v3/starlark.py b/web_interface/blueprints/api_v3/starlark.py index 980672ab..856de4a4 100644 --- a/web_interface/blueprints/api_v3/starlark.py +++ b/web_interface/blueprints/api_v3/starlark.py @@ -26,6 +26,36 @@ import web_interface.blueprints.api_v3 as _pkg # package is the only patch point that covers every caller. +def _ownership_hint(err: BaseException): + """An actionable message when the apps directory is not writable. + + The display service runs as root and the web interface as the login user + (see systemd/ledmatrix.service and systemd/ledmatrix-web.service). The + starlark-apps directory is not in the repository, so whichever service + reaches it first creates it -- and when that is the display service, the + web user cannot write into it and every install fails. + + The plugin now hands the directory back on startup, so this should not be + reachable. It is kept because the failure is otherwise invisible: the + generic message names no path and no cause, and the one user who hit it + had to read the service logs to find it. If the handover is ever prevented + -- an exotic mount, a directory root-owned for another reason -- this says + what to do instead of costing somebody an evening. + + Returns None when `err` is not a permission problem. + """ + if not isinstance(err, PermissionError): + return None + return ( + f"Cannot write to {_STARLARK_APPS_DIR}. It is owned by another user " + f"-- usually because the display service, which runs as root, created " + f"it before the web interface did. Restarting the display service " + f"(sudo systemctl restart ledmatrix) repairs the ownership " + f"automatically. To fix it by hand: " + f"sudo chown -R $USER:$USER {_STARLARK_APPS_DIR}" + ) + + @api_v3.route('/starlark/status', methods=['GET']) def get_starlark_status(): """Get Starlark plugin status and Pixlet availability.""" @@ -278,7 +308,8 @@ def upload_starlark_app(): # without it, though, and describe_exception redacts credentials and # truncates -- the same trade-off every other handler here makes. logger.exception("[Starlark] File error uploading starlark app: %s", err) - return jsonify({'status': 'error', 'message': 'File error during upload', + return jsonify({'status': 'error', + 'message': _ownership_hint(err) or 'File error during upload', 'details': describe_exception(err)}), 500 except ImportError as err: logger.exception("[Starlark] Module load error uploading starlark app: %s", err) @@ -702,6 +733,16 @@ def install_from_tronbyte_repository(): except Exception as e: logger.exception("[Starlark] install_from_tronbyte_repository failed") + hint = _ownership_hint(e) + if hint: + # `details` is kept deliberately. CodeQL flags it as information + # exposure, but this package's rule is "if it returns 5xx, it says + # why" -- enforced by test_no_api_v3_handler_discards_its_exception, + # whose PRE_EXISTING allowance may shrink and never grow. The + # Starlark routes are exactly the ones that policy was written for: + # they answered 500 with no detail for three releases. It stays. + return jsonify({'status': 'error', 'message': hint, + 'details': describe_exception(e)}), 500 return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500 @api_v3.route('/starlark/repository/categories', methods=['GET']) def get_tronbyte_categories(): From 61e462c635bdafe7477980c694e7f66b16a10272 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:33:24 -0400 Subject: [PATCH 3/4] refactor: remove the skin system and the unused src/base_classes package (#615) * refactor: remove the skin system Skins never rendered with the current scoreboard plugins: the only hook was SportsCore._render_game in src/base_classes, which no plugin builds on, so the UI and store already treated them as unsupported. The owner decided on 2026-09-23 to remove them outright. Removed src/skin_system/ (runtime, base class, fixtures), skins/, scripts/validate_skin.py and their tests; the store's "type": "skin" installer, uninstaller and hide/refuse filters (the official registry lists no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins. Stored skin/skin_options config values are handled in the next commit. Co-Authored-By: Claude Opus 5.5 * fix(config): drop retired skin/skin_options keys instead of validating them A config.json written while the skin system existed can carry skin and skin_options in any plugin section, and most plugin schemas set additionalProperties: false. They are no longer core plugin properties; RETIRED_PLUGIN_KEYS in schema_manager lists them and drop_retired_plugin_keys removes them (unless the plugin's own schema declares the name) in prepare_plugin_config, which loading, hot reload, GET /plugins/config and both web saves already share, and in validate_config_against_schema for callers that validate a raw section. POST /plugins/config and /config/main also drop them from the stored section they merge into, so they leave config.json on the next save. Tests cover the load path (real PluginManager.load_plugin: no schema warning, not degraded), raw and prepared validation, validate_all_plugin_configs, and the JSON, form and /config/main saves. Co-Authored-By: Claude Opus 5.5 * refactor: remove the unused src/base_classes package No scoreboard plugin builds on src.base_classes: the nine monorepo scoreboards ship their own sports.py and share code through src/common (docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins imports it. The one import anywhere, baseball-scoreboard's rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing instantiates. Removed the package and the eight test files that only tested it (test_api_extractors, test_data_sources, test_sports_base_characterization, test_sports_capabilities, test_sports_core_promotions, test_sports_logo_cache_bounded, test_sports_modes_promotions, test_sports_odds_fanout). test_common_is_hardware_free no longer lists src.base_classes as a forbidden import, and comments in sports_helpers.py and base_odds_manager.py stop pointing at it. Co-Authored-By: Claude Opus 5.5 * docs: drop the skin system and src/base_classes from the docs Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was removed and shared code lives in src/common, in the Layering section and the view-model-contract rule. Other docs stop pointing at the removed package. CHANGELOG records both removals under Unreleased. Co-Authored-By: Claude Opus 5.5 * fix(store): hide and refuse registry entries that aren't plugins The skin filters went with the skin system, but a custom registry can still list "type": "skin" entries, and installing one as a plugin would unpack it into the plugins directory. PluginStoreManager.is_plugin_entry() (a missing type means plugin) now hides non-plugin entries from the store and custom-registry listings, and install refuses them, in the route with a clear 400 and in _install_plugin_impl for any other caller. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .gitignore | 1 - CHANGELOG.md | 15 + CLAUDE.md | 11 - PRODUCT.md | 2 +- README.md | 9 - docs/CONFIG_REFERENCE.md | 2 +- docs/CREATING_SKINS.md | 254 ---- docs/FONT_MANAGER.md | 4 +- docs/PLUGIN_API_REFERENCE.md | 4 +- docs/PLUGIN_ARCHITECTURE_SPEC.md | 6 +- docs/PLUGIN_CONFIG_CORE_PROPERTIES.md | 15 +- docs/PLUGIN_DEVELOPMENT_GUIDE.md | 6 - docs/README.md | 2 - docs/REST_API_REFERENCE.md | 12 - docs/SKIN_SYSTEM.md | 206 --- docs/SPORTS_UNIFICATION.md | 48 +- scripts/validate_skin.py | 250 ---- skins/README.md | 28 - skins/example-classic-baseball/preview.png | Bin 5385 -> 0 bytes skins/example-classic-baseball/skin.json | 25 - skins/example-classic-baseball/skin.py | 131 -- src/base_classes/api_extractors.py | 371 ------ src/base_classes/baseball.py | 692 ---------- src/base_classes/basketball.py | 300 ----- src/base_classes/data_sources.py | 344 ----- src/base_classes/football.py | 387 ------ src/base_classes/hockey.py | 380 ------ src/base_classes/sports/__init__.py | 17 - .../sports/capabilities/__init__.py | 32 - .../sports/capabilities/celebrations.py | 418 ------ .../sports/capabilities/rotation.py | 246 ---- src/base_classes/sports/core.py | 1148 ----------------- src/base_classes/sports/modes.py | 1097 ---------------- src/base_odds_manager.py | 3 +- src/common/sports_helpers.py | 19 +- src/plugin_system/schema_manager.py | 103 +- src/plugin_system/store_manager.py | 179 +-- src/skin_system/__init__.py | 43 - src/skin_system/fixtures/baseball_live.json | 39 - src/skin_system/fixtures/baseball_recent.json | 39 - .../fixtures/baseball_upcoming.json | 39 - src/skin_system/fixtures/basketball_live.json | 28 - .../fixtures/basketball_recent.json | 28 - .../fixtures/basketball_upcoming.json | 28 - src/skin_system/fixtures/football_live.json | 36 - src/skin_system/fixtures/football_recent.json | 36 - .../fixtures/football_upcoming.json | 36 - src/skin_system/fixtures/hockey_live.json | 32 - src/skin_system/fixtures/hockey_recent.json | 32 - src/skin_system/fixtures/hockey_upcoming.json | 32 - src/skin_system/fixtures/placeholder_away.png | Bin 444 -> 0 bytes src/skin_system/fixtures/placeholder_home.png | Bin 446 -> 0 bytes src/skin_system/skin_base.py | 171 --- src/skin_system/skin_runtime.py | 352 ----- test/fixtures/api_v3_url_map.json | 9 - test/test_api_extractors.py | 342 ----- test/test_common_is_hardware_free.py | 11 +- test/test_data_sources.py | 307 ----- test/test_discovery_path_contract.py | 27 - test/test_retired_plugin_config_keys.py | 167 +++ test/test_skin_runtime_cache.py | 377 ------ test/test_skin_system.py | 571 -------- test/test_sports_base_characterization.py | 660 ---------- test/test_sports_capabilities.py | 1077 ---------------- test/test_sports_core_promotions.py | 613 --------- test/test_sports_logo_cache_bounded.py | 120 -- test/test_sports_modes_promotions.py | 585 --------- test/test_sports_odds_fanout.py | 136 -- test/test_store_non_plugin_entries.py | 79 ++ test/test_version_comparison_consistency.py | 26 +- .../test_plugin_config_json_saves.py | 54 +- web_interface/blueprints/api_v3/__init__.py | 5 +- web_interface/blueprints/api_v3/config.py | 8 +- web_interface/blueprints/api_v3/misc.py | 47 +- web_interface/blueprints/api_v3/plugins.py | 32 +- 75 files changed, 431 insertions(+), 12560 deletions(-) delete mode 100644 docs/CREATING_SKINS.md delete mode 100644 docs/SKIN_SYSTEM.md delete mode 100644 scripts/validate_skin.py delete mode 100644 skins/README.md delete mode 100644 skins/example-classic-baseball/preview.png delete mode 100644 skins/example-classic-baseball/skin.json delete mode 100644 skins/example-classic-baseball/skin.py delete mode 100644 src/base_classes/api_extractors.py delete mode 100644 src/base_classes/baseball.py delete mode 100644 src/base_classes/basketball.py delete mode 100644 src/base_classes/data_sources.py delete mode 100644 src/base_classes/football.py delete mode 100644 src/base_classes/hockey.py delete mode 100644 src/base_classes/sports/__init__.py delete mode 100644 src/base_classes/sports/capabilities/__init__.py delete mode 100644 src/base_classes/sports/capabilities/celebrations.py delete mode 100644 src/base_classes/sports/capabilities/rotation.py delete mode 100644 src/base_classes/sports/core.py delete mode 100644 src/base_classes/sports/modes.py delete mode 100644 src/skin_system/__init__.py delete mode 100644 src/skin_system/fixtures/baseball_live.json delete mode 100644 src/skin_system/fixtures/baseball_recent.json delete mode 100644 src/skin_system/fixtures/baseball_upcoming.json delete mode 100644 src/skin_system/fixtures/basketball_live.json delete mode 100644 src/skin_system/fixtures/basketball_recent.json delete mode 100644 src/skin_system/fixtures/basketball_upcoming.json delete mode 100644 src/skin_system/fixtures/football_live.json delete mode 100644 src/skin_system/fixtures/football_recent.json delete mode 100644 src/skin_system/fixtures/football_upcoming.json delete mode 100644 src/skin_system/fixtures/hockey_live.json delete mode 100644 src/skin_system/fixtures/hockey_recent.json delete mode 100644 src/skin_system/fixtures/hockey_upcoming.json delete mode 100644 src/skin_system/fixtures/placeholder_away.png delete mode 100644 src/skin_system/fixtures/placeholder_home.png delete mode 100644 src/skin_system/skin_base.py delete mode 100644 src/skin_system/skin_runtime.py delete mode 100644 test/test_api_extractors.py delete mode 100644 test/test_data_sources.py create mode 100644 test/test_retired_plugin_config_keys.py delete mode 100644 test/test_skin_runtime_cache.py delete mode 100644 test/test_skin_system.py delete mode 100644 test/test_sports_base_characterization.py delete mode 100644 test/test_sports_capabilities.py delete mode 100644 test/test_sports_core_promotions.py delete mode 100644 test/test_sports_logo_cache_bounded.py delete mode 100644 test/test_sports_modes_promotions.py delete mode 100644 test/test_sports_odds_fanout.py create mode 100644 test/test_store_non_plugin_entries.py diff --git a/.gitignore b/.gitignore index 3b59b144..fea80d19 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ config/backups/ # Starlark apps runtime storage (installed .star files and cached renders) /starlark-apps/ -skin_renders/ # JS test deps (test/js) node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f9cbb9..0e07b552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,21 @@ floor on the release that ships them): - The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating errors and a Clear button. +### Removed + +- **The skin system.** Skins never rendered with the current scoreboard + plugins, so they are gone rather than "not supported yet": `src/skin_system/`, + `skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's + `"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`. + A `skin` or `skin_options` key left in a plugin's saved config still loads + and saves without a validation error; it is ignored, and the next save of + that plugin's settings removes it (unless the plugin's own schema declares + the key). +- **`src/base_classes/`** (`SportsCore`, the sport and mode classes, + `CelebrationMixin`, the rotation strategies, `data_sources`, + `api_extractors`). No known plugin imports it. A plugin that does must use + `src.common` or its own copy of the code. + ## 3.5.0 New modules a plugin may import via `src.*` (floor on 3.5.0): diff --git a/CLAUDE.md b/CLAUDE.md index 08942c40..714ff7b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,17 +45,6 @@ - Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls - Third-party plugins can use their own repo URL with empty `plugin_path` -## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET -- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes` -- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` -- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section) -- Skins live in `skins//` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs -- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests -- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session) -- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins -- Validate skins headlessly: `python scripts/validate_skin.py --skin `; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md` -- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed - ## Common Pitfalls - paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat - BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()` diff --git a/PRODUCT.md b/PRODUCT.md index b0b2981b..23488754 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -44,7 +44,7 @@ Four strengths define LEDMatrix, and future work must protect all of them: - **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working. - **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls. - **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets. -- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode. +- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, on-demand, AP mode. - **Open decisions** (offered during init, not adopted as constraints): - Whether the UI must work fully offline, with no CDN fallbacks at runtime. - Whether a Node/CSS build step is acceptable for contributors. diff --git a/README.md b/README.md index 7b7b8b7f..7ae97dd0 100644 --- a/README.md +++ b/README.md @@ -460,15 +460,6 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl For plugin development, the `plugins/hello-world/` plugin in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository is a starter template. -### Visual Skins for Scoreboards - -**Not supported yet.** Skins are meant to restyle a sports scoreboard's -live/recent/upcoming screens without forking the plugin, but the current -scoreboard plugins don't render them: a selected skin has no effect. The web -UI doesn't offer skin install or selection for that reason. The skin system -and its docs stay in place for when scoreboards adopt it; see -[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why. - **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility. diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 96f6b741..58eb7df7 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -104,7 +104,7 @@ logical image to multiple chained physical panels. |---|---|---|---| | `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) | | `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) | -| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` | +| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config | | `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) | ## `display.vegas_scroll` — continuous scroll mode diff --git a/docs/CREATING_SKINS.md b/docs/CREATING_SKINS.md deleted file mode 100644 index d64bc6ba..00000000 --- a/docs/CREATING_SKINS.md +++ /dev/null @@ -1,254 +0,0 @@ -# Creating Skins - -> **Not supported yet: skins don't render with the current scoreboard -> plugins.** The only render hook is `SportsCore._render_game()` in -> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or -> third-party) builds on `src.base_classes`, so a skin you build here passes -> `validate_skin.py` but never appears on the matrix. The web UI and Plugin -> Store don't offer skins for that reason. Details: -> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below -> stays accurate for the skin API itself. - -A skin restyles a sports scoreboard (live / recent / upcoming) without -forking the plugin: the plugin keeps fetching data, scheduling, caching, and -doing vegas mode; your skin only draws. Architecture background: -[SKIN_SYSTEM.md](SKIN_SYSTEM.md). - -## Quick start - -```bash -cp -r skins/example-classic-baseball skins/my-skin -# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name -# edit skins/my-skin/skin.py -> rename the class, start restyling -python scripts/validate_skin.py --skin my-skin -``` - -The validator renders your skin against bundled fixture games at several -panel sizes with **no hardware, no network, no running service**, saves PNGs -(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate: -edit → validate → look at the PNGs. - -To select it, add to your plugin's section in `config/config.json` (this is -stored and validated, but has no visible effect until a scoreboard uses the -skin hook — see the note at the top): - -```json -"baseball-scoreboard": { - "skin": "my-skin", - "skin_options": { } -} -``` - -The web UI's **Visual Skin** dropdown is hidden while skins are unsupported. -`"skin"` also accepts a per-mode mapping: -`{"live": "my-skin", "recent": "built-in"}`. - -## The manifest (`skin.json`) - -```json -{ - "id": "my-skin", - "name": "My Skin", - "version": "1.0.0", - "author": "you", - "description": "What it looks like", - "skin_api_version": "1.0.0", - "targets": { - "sports": ["baseball"], - "sport_keys": ["mlb", "milb"], - "plugins": [] - }, - "entry_point": "skin.py", - "class_name": "MySkin", - "modes": ["live", "recent", "upcoming"], - "preview": "preview.png" -} -``` - -Field notes: `id` must equal the directory name; `skin_api_version`'s major -version must match the host's `SKIN_API_VERSION` or the skin is refused at -load; `targets` takes sport families (`sports`), exact sport keys -(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies. - -## The renderer (`skin.py`) - -```python -from src.skin_system.skin_base import ScoreboardSkin, SkinContext - -class MySkin(ScoreboardSkin): - def render_live(self, ctx: SkinContext, game: dict) -> bool: - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - fit = ctx.layout.fit_text(score, ctx.layout.bounds) - ctx.draw_fit(fit, ctx.layout.bounds) - return True # True = "I drew it"; False = use the built-in layout -``` - -Implement only the modes you care about — anything else falls back to the -plugin's built-in rendering. Return `False` to decline a specific game (e.g. -a layout that only makes sense while a game is live). - -### The rules (they keep your skin from breaking the display) - -1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never - reassign `ctx.canvas`, never touch the display or call any update method. -2. **No I/O in render paths.** No network, no file loads per frame — - `render_live` runs every display pass, and a slow render stalls the whole - matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and - `cache_key=` for images. -3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the - live/recent/upcoming modes each get their own instance. -4. **Always `.get()` optional keys.** Only the guaranteed keys below are - promised to exist. -5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/ - `ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run - at sizes you didn't test (64x32, 128x64, vegas cards). -6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides. - -A skin that raises 3 renders in a row is disabled until the service restarts -(the built-in layout takes over), so a bug is cosmetic — but check your logs. - -## SkinContext reference - -| Member | What it is | -|---|---| -| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) | -| `ctx.width`, `ctx.height` | Canvas size — the only size truth | -| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` | -| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) | -| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) | -| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` | -| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below | -| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) | -| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` | -| `ctx.options` | Your user's `skin_options` from config | -| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger | - -**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one -sanctioned exception. It goes through the host's logo cache — after the -first call per team it's a pure in-memory lookup. If a logo file is missing -on disk, the *first* call may download it, exactly like the built-in -renderer does for the same game (a skin is never worse than built-in here). -Always pass a stable `cache_key` when drawing it, never load image files -yourself in a render path, and always handle `None`. - -The default layout idiom — carve regions, then fit text into them: - -```python -from src.adaptive_layout import scoreboard_regions - -regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) -ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}") -ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}") -fit = ctx.layout.fit_text("3-5", regions.score_area) -ctx.draw_fit(fit, regions.score_area) -``` - -`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/ -`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/ -ellipse/...` is always available for custom marks (see the bases diamond in -the example skin). - -## The game view model - -Guaranteed for every sport (view model v1.0 — renaming these breaks skins and -is treated as a breaking change upstream): - -| Key | Notes | -|---|---| -| `id` | Event id (string) | -| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` | -| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans | -| `game_date`, `game_time` | Pre-formatted local date/time strings | -| `start_time_utc` | UTC `datetime` | -| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) | -| `home_id`, `away_id` | Team ids | -| `home_score`, `away_score` | **Strings**, not ints | -| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) | -| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these | - -Sport extras (present for that sport, still `.get()` defensively): - -- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`, - `strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]` - booleans), `series_summary` (str) -- **football**: `period`, `period_text`, `clock`, `home_timeouts`, - `away_timeouts`, `down_distance_text`, `down_distance_text_long`, - `is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`), - `scoring_event` -- **basketball**: `period`, `period_text`, `clock` -- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`, - `home_shots`, `away_shots` - -Optional everywhere (only when the user enabled the feature): `odds` (dict), -`series_summary`, rankings-related fields. - -Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's -exactly what the validator feeds your skin. - -## Vegas mode - -You get vegas support for free: vegas captures the normal display output, -which is already your skin's rendering. Optionally implement -`render_vegas_card(ctx, game)` to return a purpose-built card at -`ctx.width x ctx.height` (sizes vary — never assume 128x32). - -## Building a skin with Claude Code - -Skins are ideal Claude Code projects: small, isolated, and verifiable with -one command. Paste this to start: - -> You are building a **display skin** for LEDMatrix — a visual overlay for a -> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels). -> First read `docs/CREATING_SKINS.md` and the reference skin in -> `skins/example-classic-baseball/`. -> -> Rules: -> - Create/modify files ONLY under `skins//`. Do NOT modify -> anything in `src/`, `scripts/`, the plugins, or any other skin. -> - Render only from the `game` dict and `ctx` helpers. No network calls, no -> per-frame file I/O, no new pip dependencies, no touching the display — -> draw onto `ctx.canvas` and return True. -> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works -> at any panel size; use `.get()` for every optional game key. -> - After every change run -> `python scripts/validate_skin.py --skin ` and LOOK at the -> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to -> read). Iterate until it passes and looks right at both 128x32 and 64x32. -> -> What I want it to look like: status go; colors; what shows during live vs upcoming vs final> - -Tips that keep Claude (and you) out of trouble: - -- One mode at a time: get `render_live` right before touching the others — - unimplemented modes automatically use the built-in look. -- Ask for edge-case renders: long team abbreviations, missing logos - (`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT. -- If the render looks cramped at 64x32, ask Claude to use - `ctx.layout.by_tier(...)` to drop elements on small panels rather than - shrinking everything. -- Never let it "fix" a problem by editing `src/` — if the skin can't do - something within its directory, that's a feature request, not a workaround. - -## Pre-publish checklist - -- [ ] `python scripts/validate_skin.py --skin --size 128x32 --size 64x32 --size 128x64` passes -- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping -- [ ] Handles a missing logo (`None`) without crashing — temporarily point a - fixture's logo path at a nonexistent file to test -- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow -- [ ] No render warning above the time budget -- [ ] `skin.json`: `id` matches the directory, `version` set, - `skin_api_version` matches the host, targets correct -- [ ] `preview.png` added (grab your favorite `_x4` render) -- [ ] Tested on real hardware if you have it — a Pi is much slower than your - dev machine - -Distribute by publishing the directory as a git repo (users -`git clone skins/`). Registry entries with `"type": "skin"` are -hidden and refused by the Plugin Store while skins are unsupported (see -[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution). - -**Trust note:** a skin is Python running inside the display service — the -same trust level as a plugin. Review code before installing skins from -others. diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 3678e955..1acf32f1 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -393,7 +393,5 @@ self.font = self.font_manager.resolve_font( ## Example: Complete Manager Implementation For a working example of the font manager API in use, see -`src/font_manager.py` itself and the bundled scoreboard base classes -in `src/base_classes/` (e.g., `hockey.py`, `football.py`) which -register and resolve fonts via the patterns documented above. +`src/font_manager.py` itself. diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index eb1fa985..5aadaadc 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -430,9 +430,7 @@ self.display_manager.image.paste(icon, (5, 5), icon) self.display_manager.update_display() ``` -This is the same pattern the bundled scoreboard base classes -(`src/base_classes/baseball.py`, `basketball.py`, `football.py`, -`hockey.py`) use, so it's the canonical way to render arbitrary images. +This is the canonical way to render arbitrary images. ### Weather Icons diff --git a/docs/PLUGIN_ARCHITECTURE_SPEC.md b/docs/PLUGIN_ARCHITECTURE_SPEC.md index 8df78fd9..7a726978 100644 --- a/docs/PLUGIN_ARCHITECTURE_SPEC.md +++ b/docs/PLUGIN_ARCHITECTURE_SPEC.md @@ -12,9 +12,9 @@ > in `web_interface/app.py`. > - The default plugin location is `plugin-repos/` (configurable via > `plugin_system.plugins_directory`), not `./plugins/`. -> - Example imports use `src/plugin_system/base_classes/*_plugin.py`; -> the shipped base classes live in `src/base_classes/` (e.g. -> `src.base_classes.sports.SportsCore`, `src.base_classes.hockey.Hockey`). +> - Example imports use `src/plugin_system/base_classes/*_plugin.py`, +> which do not exist. The old `src/base_classes/` package has been +> removed; shared sports code lives in `src/common/`. > - The "Migration Strategy" and "Implementation Roadmap" sections > describe work that has now shipped. > diff --git a/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md b/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md index 753c8430..2939dd99 100644 --- a/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md +++ b/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md @@ -25,16 +25,7 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`): - Description: Enable live priority takeover when plugin has live content - Used by DisplayController for priority scheduling -4. **`skin`** (string, object or null; no default) - - Description: Visual skin id, or a per-mode mapping like `{"live": "my-skin"}` - - Not an enum, so a stored value keeps validating after the skin is - uninstalled. Skins do not render with the current scoreboard plugins; - the key is kept so stored values keep loading and saving - -5. **`skin_options`** (object; no default) - - Description: Options passed through to the selected skin - -6. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`** +4. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`** (untyped; no default) - Description: Vegas mode tuning for this plugin — card width as a percentage of the panel, `"rotate"` or `"truncate"` on overflow, and the @@ -42,6 +33,10 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`): - Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which validate the values themselves and ignore a bad one with a log line +`skin` and `skin_options` were core properties until the skin system was +removed. A plugin config saved with them still loads and saves; the keys are +dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`). + ## How Core Properties Work ### Schema Validation diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index 14b96c87..95857464 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -12,12 +12,6 @@ This guide explains how to set up a development workflow for plugins that are ma > scale. Existing plugins keep their classic rendering unless they adopt > those APIs; nothing migrates automatically. -> **Want a different look for an existing sports scoreboard?** Skins are -> meant for that, but they are **not supported yet**: the current scoreboard -> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)). -> For now, change the look through the plugin's own display settings or its -> code. - ## Overview When developing plugins in separate repositories, you need a way to: diff --git a/docs/README.md b/docs/README.md index 4e5c1342..475cb92e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,8 +56,6 @@ Going deeper: - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display, cache management, background services, permissions - [FONT_MANAGER.md](FONT_MANAGER.md) — font system -- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins) -- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat) ## Reference diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index f651a222..2c8f1e67 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -37,7 +37,6 @@ the entry below says so. - [Integrations](#integrations) - [Plugin-specific endpoints](#plugin-specific-endpoints) - [Starlark Apps](#starlark-apps) -- [Skins](#skins) > The API blueprint is the `api_v3` package in > `web_interface/blueprints/api_v3/` (one module per area: `config.py`, @@ -2084,17 +2083,6 @@ runs. --- -## Skins - -**GET** `/api/v3/skins` - -Installed scoreboard skins (optional `?plugin_id=` filter). Skins are not -supported by the current scoreboard plugins, so the response carries -`data.supported: false` and a `data.message`; clients must not offer these -as selectable. See [SKIN_SYSTEM.md](SKIN_SYSTEM.md). - ---- - ## Error Responses Errors use one of two shapes. Most endpoints answer: diff --git a/docs/SKIN_SYSTEM.md b/docs/SKIN_SYSTEM.md deleted file mode 100644 index 2ab85ad4..00000000 --- a/docs/SKIN_SYSTEM.md +++ /dev/null @@ -1,206 +0,0 @@ -# Skin System Architecture - -## Status: not supported yet - -**Skins don't render with the current scoreboard plugins.** The skin system -below works in isolation (it loads, validates and renders skins in -`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a -running display calls it: - -- The only render hook is `SportsCore._render_game()` in - `src/base_classes/sports/core.py`. -- None of the current scoreboard plugins build on `src.base_classes`. The - official scoreboards in the `ledmatrix-plugins` monorepo, and the - third-party scoreboards in the plugin registry, carry their own sports and - rendering code (with the shared `src/common/sports_*` helpers) and never - reach `SportsCore._render_game()`. - -So a skin can be dropped into `skins/` and named in a plugin's config, but the -scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the -hook, core does not offer skins to users: - -- The plugin config page shows no **Visual Skin** dropdown. -- The Plugin Store hides registry entries with `"type": "skin"` and refuses - to install one (`POST /api/v3/plugins/install` answers 400 with the reason). -- `GET /api/v3/skins` still lists what is in `skins/`, with - `"supported": false` and a `message`. -- A config that already contains `"skin"` / `"skin_options"` still loads, - validates and saves unchanged; the value is simply unused. - -The rest of this document describes the design as built, for whoever wires a -scoreboard to it. - -Skins are user-installable **visual overlays** for the sports scoreboards. -A skin replaces only the *look* of a scoreboard — the host plugin keeps doing -data fetching, scheduling, caching, dedup, live-priority takeover, and vegas -mode. If you only want to **build** a skin, read -[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system -works and why it is shaped this way. - -## Why skins instead of forks - -Before skins, changing a scoreboard's layout meant forking the whole plugin -(e.g. the community MLB scoreboard fork). The fork gets the new look but loses -everything the maintained plugin keeps earning: duration/scheduling behavior, -vegas mode support, caching and background-fetch improvements, bug fixes. It -also silently drifts: every upstream improvement now has to be re-ported by -hand. - -A skin inverts that trade. The plugin remains stock and keeps updating through -the store; the skin is ~100 lines of pure rendering code that receives the -plugin's already-fetched data each frame. Uninstalling the skin (or the skin -crashing) simply restores the built-in look. - -```text - (unchanged) (the skin seam) - ESPN API ──► update() ──► game view model ──► _render_game() ──► display - fetching (a dict) │ │ - caching │ └─ built-in - scheduling └─ skin.render_(ctx, game) - live priority draws onto ctx.canvas -``` - -## The render funnel - -A sports scoreboard built on the `src/base_classes/sports/` package -(`core.py`) renders through exactly one seam. No current scoreboard plugin is -built on it (see [Status](#status-not-supported-yet)), so for them this seam is -never reached: -`SportsCore._render_game(game, force_clear)`. - -1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`) - picks `self.current_game` and calls `_render_game`. -2. `_render_game` lazily loads the configured skin (once, on first render — - a broken skin can never block plugin startup). -3. If a skin is active, the host builds a `SkinContext` — a fresh black - canvas at the current display size plus layout/font/logo helpers — and - calls the skin's `render_live` / `render_recent` / `render_upcoming` - with a **copy** of the game dict. -4. If the skin returns `True`, the canvas is composited onto the display. - If it returns `False`, isn't implemented for that mode, or raises, the - built-in `_draw_scorebug_layout` runs instead. - -Key properties that fall out of this design: - -- **Per-mode fallback.** A skin that only implements `render_live` gets the - stock recent/upcoming screens for free. -- **Three strikes.** A skin that raises 3 times in a row is disabled for the - rest of the session (one loud error log per failure); the display never - goes dark. Restarting the service re-arms it. -- **Copies, not references.** Skins receive a shallow copy of the game dict, - so a buggy skin cannot corrupt the plugin's scheduling state. -- **Vegas mode works untouched.** Vegas capture falls back to grabbing the - regular `display()` output, which is already skin-rendered. Skins can - additionally implement `render_vegas_card` for purpose-built scroll cards, - and hosts can call `SportsCore.render_skin_card(game, size)` to use it. -- **Hot-loop caution.** `render_live` runs every display-loop pass during a - live game. The host logs a warning when a skin render exceeds 150 ms, and - `scripts/validate_skin.py` enforces a budget at development time — but - Python cannot forcibly time-out a stuck render, so a skin that blocks - (network I/O, giant image ops) stalls the display. This is why the rules - in CREATING_SKINS.md ban I/O in render paths. - -## The view model contract - -The `game` dict a skin receives is the plugin's already-extracted view model -(`SportsCore._extract_game_details_common` plus per-sport extras from -`src/base_classes/{baseball,basketball,football,hockey}.py`). - -- **Guaranteed keys (view model v1.0)** — always present for every sport: - `id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`), - `status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`, - `home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score` - (**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`. -- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball - adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`). -- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only - when the feature is enabled — skins must always use `.get()`. - -Versioning policy: additive changes bump the minor version -(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as -`ctx.view_model_version`); renaming or removing a guaranteed key requires a -major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract` -fails CI if a guaranteed key disappears from the extractor. - -Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`, -`SkinContext`). The loader refuses a skin whose manifest declares a different -major version and falls back to the built-in renderer with a clear -"skin needs an update" log line. - -## Package layout and lifecycle - -```text -skins// - skin.json # manifest (required) - skin.py # ScoreboardSkin subclass (required) - preview.png # optional, shown by the web UI - assets/ # optional skin-local images - helpers.py ... # optional extra modules (namespaced per skin at import) -``` - -Skins live in the central `skins/` directory — deliberately **not** inside the -plugin's directory, because plugin reinstall/update deletes the whole plugin -directory and a skin must survive that. One skin can also target several -plugins (mlb + milb). - -Lifecycle: discovered lazily on first render → manifest validated → API major -version gated → module imported under a namespaced `sys.modules` key (two -skins can both ship a `helpers.py`, same scheme plugins use) → instantiated -with `(manifest, options)`. Every failure logs and falls back to built-in. - -Skins should be **stateless**: the live, recent, and upcoming mode classes -each hold their own skin instance, so derive everything from `(ctx, game)`. - -## Selection and configuration - -Inside the plugin's own config section in `config/config.json`: - -```json -"baseball-scoreboard": { - "skin": "retro-baseball", - "skin_options": { "accent_color": [255, 80, 0] } -} -``` - -`"skin"` is either one id for all modes or a per-mode mapping -(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or -`"built-in"` means the stock renderer. Because this rides the plugin's config -section, it persists across plugin reinstalls like every other setting. - -`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the -*served* schema for plugins with matching skins installed. While skins are -unsupported the plugin schema endpoint does not call it, so the dropdown is -not shown. Validation never sees the enum either way: the base schema allows -any `skin` value, so a config that references an uninstalled skin stays valid. -`GET /api/v3/skins` lists installed skins (optionally filtered by -`?plugin_id=`) and reports `"supported": false`. - -## Distribution - -- **Manual:** `git clone skins/` — that's the whole - install. No manifest bumps, no `update_registry.py`; skins are not monorepo - plugins. -- **Store (disabled while unsupported):** registry entries with - `"type": "skin"` are hidden from the store list and refused on install. - `PluginStoreManager._install_skin_from_info` is kept: once - `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such - entries install through the same `plugins.json` pipeline, land in `skins/`, - are validated against `skin.json` (including the API major version) instead - of `manifest.json`, and never install dependencies — skins are render-only - (stdlib + PIL + the provided context, no third-party packages in v1). - -## Trust model - -A skin is Python executing inside the display service — **exactly the same -trust level as a plugin**, even though "skin" sounds cosmetic. Only install -skins from sources you'd be willing to install a plugin from. - -## v2 directions (not in v1) - -- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins - (weather, music) can offer skinnable layouts; `skin_runtime` is already - sports-agnostic in anticipation. -- Store UI: preview gallery, one-click install from the skin browser. -- An update path for git-cloned skins (today: re-clone or store reinstall). -- Animation support in skins (today the API is one frame per render call; - stateful tricks work but are at-your-own-risk). diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 1078f1c5..015c6e2d 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -7,9 +7,9 @@ becoming nine clients of a god class. Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`, `nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this -repo's `src/base_classes/sports.py`. They have drifted into three lineages, and -only 28 of the 66 methods appearing across them are present in all nine. One -logical fix (the UTC start-time bug) cost 75 files. +repo's former `src/base_classes/sports.py` (since removed). They have drifted +into three lineages, and only 28 of the 66 methods appearing across them are +present in all nine. One logical fix (the UTC start-time bug) cost 75 files. Merging everything into one base class would fix the duplication and create a worse problem: a single 2,500-line class that all nine plugins inherit, where any @@ -26,7 +26,7 @@ These are independent concerns. Conflating them is what produces god classes. |---|---| | Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) | | Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. | -| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. | +| Core changes never break a plugin's rendering | The **view-model contract**: the game dict each plugin's `_extract_game_details_common` builds is read by the shared `src/common` renderers, so its keys may be added, never renamed or removed. | | A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. | The core API is **additive-only**. A method the plugins call is never removed or @@ -67,16 +67,13 @@ This is the property the naive merge destroys, and it is enforced structurally: ## Layering -``` -src/base_classes/sports/ - __init__.py re-exports the public API (import path unchanged) - core.py SportsCore — fetch, cache, config, logos, fonts, odds, - view-model extraction, the skin seam - modes.py SportsUpcoming / SportsRecent / SportsLive - capabilities/ - celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins) - rotation.py RotationStrategy + registry +`src/base_classes/` has been removed: no scoreboard plugin built on it. B1 and +B2 below promoted code into it (`SportsCore`, the mode classes, +`CelebrationMixin`, the rotation strategies); the override points and +capabilities sections record that design, but none of it ships in core any +more. Shared sports code lives in `src/common`: +``` src/common/ sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration (content building stays in the plugins) @@ -85,13 +82,10 @@ src/common/ plugins' sports.py, and the _favorite_key seam ``` -`from src.base_classes.sports import SportsCore` keeps working — the package -`__init__` re-exports, so the conversion is invisible to every existing importer. - ### Converging on `src/common` -The scoreboards do not build on `src/base_classes`; their own `sports.py` copies -have moved past it. So shared code now lands in hardware-free `src/common` +The scoreboards never built on `src/base_classes` (now removed); their own +`sports.py` copies had moved past it. So shared code now lands in hardware-free `src/common` modules taken from the plugin copies, each a **new module** rather than growth on an existing one: a plugin that deletes a method copy and relies on an older module having gained it fails at runtime with an `AttributeError`, while a @@ -100,7 +94,7 @@ missing module fails at load, where the version checks can see it. listed below, for later phases); its parity test compares every body against the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and `test/test_common_is_hardware_free.py` keeps `src/common` free of -`rgbmatrix`, `src.base_classes` and `src.plugin_system`. How a plugin adopts a +`rgbmatrix`, `src.display_manager` and `src.plugin_system`. How a plugin adopts a module and drops its copy is documented in the plugins repo's `docs/plugin-development/08-shared-sports-code.md`. @@ -116,7 +110,6 @@ deprecation cycle. | `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` | | `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout | | `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | -| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback | | `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `" SCORES!"` — only consulted when `CelebrationMixin` is present | | `win_phrase(team_abbr)` | Win-celebration wording | `" WINS!"` — mixin only | | `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["_abbr"]` | @@ -189,9 +182,9 @@ and a typo should cost the boost, not the scoreboard. When a plugin needs an ordering that core does not ship, it calls `register_rotation_strategy` to add its own — rather than core growing a branch for it. -`test_sports_capabilities.py` checks each strategy against a **verbatim -transcription** of the plugin code it replaces, over every live-game shape up to -four games. That differential is what B5 deletes the bundled copies on the +`test_sports_capabilities.py` (removed with `src/base_classes`) checked each +strategy against a **verbatim transcription** of the plugin code it replaces, +over every live-game shape up to four games. That differential is what B5 deletes the bundled copies on the strength of. ## Scroll display — where the promotion line falls @@ -507,9 +500,9 @@ What actually remains, smallest first: 3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`, `base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is the largest single duplication left: ~11,500 lines across eight plugins, with - ~36,500 more in the eight `sports.py`. Note that core already ships - `src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin - imports** — check whether it has drifted before treating it as the target. + ~36,500 more in the eight `sports.py`. The `src/base_classes/sports/` + package promoted in B1/B2 was never imported by a plugin and has been + removed, so the plugin copies are the only starting point. ## How to keep this project healthy @@ -542,6 +535,7 @@ Lessons this migration paid for, worth applying beyond it: - **A capability that is not opted into must not execute.** If you find yourself writing `if self._enabled` inside a base class, it belongs in a mixin. -- **Touch the view-model keys only additively.** Published skins depend on them. +- **Touch the view-model keys only additively.** The shared `src/common` + renderers read them. - **Every promotion lands with the characterization suite green**, and every pilot adoption lands with that plugin's harness and golden suites green. diff --git a/scripts/validate_skin.py b/scripts/validate_skin.py deleted file mode 100644 index 53cb6ebe..00000000 --- a/scripts/validate_skin.py +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env python3 -""" -Headless skin validator — render a skin against bundled fixture games at -multiple panel sizes without hardware, a network, or a running service. - - python scripts/validate_skin.py --skin my-skin - python scripts/validate_skin.py --skin my-skin --sport baseball \ - --size 128x32 --size 64x32 --output-dir /tmp/skin_renders - -For each (mode x size) it checks: the manifest loads and its API version -matches, the render raises no exception, the canvas isn't blank, and the -render finishes inside a time budget (warn — the live renderer runs every -display-loop pass, and a Pi is far slower than your dev machine). PNGs are -saved (native plus 4x nearest-neighbor previews) so you can eyeball the -result. Exit code is non-zero when any check fails. -""" - -import argparse -import json -import logging -import sys -import time -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(PROJECT_ROOT)) - -from PIL import Image, ImageDraw, ImageFont # noqa: E402 - -from src.common.font_layout import load_truetype # noqa: E402 - -FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" -MODES = ("live", "recent", "upcoming") -SPORTS = ("baseball", "basketball", "football", "hockey") -RENDER_BUDGET_S = 0.100 - - -class FixtureHost: - """Stands in for a SportsCore instance: fonts, logger, logo loading, - outlined text — everything build_context needs, no network.""" - - def __init__(self, sport: str, skin_options: dict) -> None: - self.sport = sport - self.sport_key = sport - self.skin_options = skin_options - self.logger = logging.getLogger(f"validate_skin.{sport}") - self.fonts = self._load_fonts() - self._logo_cache = {} - self.display_manager = None # build_context is always given a size - - def _load_fonts(self) -> dict: - """Load the SportsCore font set (TTF, with PIL default fallback).""" - fonts = {} - try: - press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") - small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") - fonts['score'] = load_truetype(press, 10) - fonts['time'] = load_truetype(press, 8) - fonts['team'] = load_truetype(press, 8) - fonts['status'] = load_truetype(small, 6) - fonts['detail'] = load_truetype(small, 6) - fonts['rank'] = load_truetype(press, 10) - except IOError: - default = ImageFont.load_default() - for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): - fonts[key] = default - return fonts - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, - logo_path, logo_url) -> "Image.Image | None": - """Load a fixture logo from disk (no downloads), cached per team.""" - if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] - path = Path(logo_path) - if not path.is_absolute(): - path = PROJECT_ROOT / path - if not path.exists(): - return None - logo = Image.open(path).convert('RGBA') - self._logo_cache[team_abbrev] = logo - return logo - - def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str, - position: tuple, font, - fill: tuple = (255, 255, 255), - outline_color: tuple = (0, 0, 0)) -> None: - """Classic outlined scorebug text, same as SportsCore's helper.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), - (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - -def load_fixture(sport: str, mode: str) -> dict: - with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f: - game = json.load(f) - # Real view models carry start_time_utc as a UTC datetime, not a string. - if isinstance(game.get("start_time_utc"), str): - from datetime import datetime - game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"]) - return game - - -def parse_size(value: str) -> "tuple[int, int]": - try: - w_text, h_text = value.lower().split("x") - w, h = int(w_text), int(h_text) - except ValueError as exc: - raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc - if w <= 0 or h <= 0: - raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}") - return w, h - - -def parse_options(value: str) -> dict: - try: - options = json.loads(value) - except json.JSONDecodeError as exc: - raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc - if not isinstance(options, dict): - raise argparse.ArgumentTypeError("options must be a JSON object") - return options - - -def display_path(path: Path) -> str: - """Repo-relative when inside the repo, absolute otherwise (--output-dir - may point anywhere, e.g. /tmp/skin_renders).""" - try: - return str(path.relative_to(PROJECT_ROOT)) - except ValueError: - return str(path) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)") - parser.add_argument("--sport", choices=SPORTS, - help="fixture sport (default: first sport the skin targets, else baseball)") - parser.add_argument("--size", action="append", type=parse_size, dest="sizes", - metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)") - parser.add_argument("--output-dir", type=Path, - default=PROJECT_ROOT / "skin_renders", - help="where rendered PNGs are written") - parser.add_argument("--options", type=parse_options, default={}, - help="skin_options JSON to pass the skin") - args = parser.parse_args() - sizes = args.sizes or [(128, 32), (64, 32)] - - logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") - - from src.skin_system import skin_runtime - from src.skin_system.skin_base import SKIN_API_VERSION - - skins = skin_runtime.discover_skins() - manifest = skins.get(args.skin) - if manifest is None: - print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}") - if skins: - print(f" installed skins: {', '.join(sorted(skins))}") - return 1 - - sport = args.sport - if sport is None: - declared = skin_runtime.skin_targets(manifest)[0] - sport = next((s for s in declared if s in SPORTS), "baseball") - - skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport, - options=args.options) - if skin is None: - print(f"FAIL: skin '{args.skin}' did not load " - f"(see log above; host API is {SKIN_API_VERSION})") - return 1 - - host = FixtureHost(sport, args.options) - args.output_dir.mkdir(parents=True, exist_ok=True) - failures = 0 - rendered = 0 - - for mode in MODES: - game = load_fixture(sport, mode) - render = getattr(skin, f"render_{mode}") - for width, height in sizes: - label = f"{mode}@{width}x{height}" - try: - # Warm-up render absorbs one-time font/image loads, second - # render is the one timed against the budget. - ctx = skin_runtime.build_context(host, game, size=(width, height)) - handled = render(ctx, dict(game)) - if handled: - ctx = skin_runtime.build_context(host, game, size=(width, height)) - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - else: - elapsed = 0.0 - except Exception as e: - print(f"FAIL {label}: render raised {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - failures += 1 - continue - - if not handled: - print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)") - continue - - if ctx.canvas.size != (width, height): - print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it") - failures += 1 - continue - if ctx.canvas.convert("L").getbbox() is None: - print(f"FAIL {label}: canvas is blank — render returned True but drew nothing") - failures += 1 - continue - if elapsed > RENDER_BUDGET_S: - print(f"WARN {label}: render took {elapsed * 1000:.0f}ms " - f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)") - - out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png" - ctx.canvas.save(out) - preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST) - preview.save(out.with_name(out.stem + "_x4.png")) - print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}") - rendered += 1 - - # Vegas card, once per mode at the first size (optional API) - try: - width, height = sizes[0] - ctx = skin_runtime.build_context(host, game, size=(width, height)) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png" - card.save(out) - print(f"ok {mode} vegas card -> {display_path(out)}") - except Exception as e: - print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}") - failures += 1 - - if rendered == 0 and failures == 0: - print(f"FAIL: skin '{args.skin}' rendered nothing — no render_ returned True") - return 1 - print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures " - f"(PNGs in {args.output_dir})") - return 1 if failures else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skins/README.md b/skins/README.md deleted file mode 100644 index 78cb534e..00000000 --- a/skins/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# skins/ - -> **Not supported yet.** The current scoreboard plugins don't render skins, -> so a skin placed here and selected in config has no effect, and the web UI -> and Plugin Store don't offer them. See -> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet). - -User-installable **visual skins** for the sports scoreboards. Each -subdirectory is one skin: - -```text -skins// - skin.json # manifest - skin.py # renderer (a ScoreboardSkin subclass) - preview.png # optional -``` - -- Install a skin: `git clone skins/`. The Plugin Store - refuses registry entries with `"type": "skin"` while skins don't render. -- Select it: set `"skin": ""` in the plugin's section of - `config/config.json`. The web UI no longer shows a Visual Skin dropdown. -- Build one: start from `example-classic-baseball/` and read - [docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with - `python scripts/validate_skin.py --skin `. - -Skins survive plugin reinstalls/updates (that's why they live here and not in -the plugin's directory). A skin is Python at the same trust level as a -plugin — review before installing. diff --git a/skins/example-classic-baseball/preview.png b/skins/example-classic-baseball/preview.png deleted file mode 100644 index 37ad2dad218228af08ddfdd548fada9648962677..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5385 zcmaJ_c|4SD+a61{Vr(H{648@vFCt=up|VGKlBK4kv1eb#h%6CPkCN=BP}v_#2$O_7 zVceyWEnAskFku#Eee3Q0ec$u`Uf=iEegAR)uKT=>>$r~dIL)(;q(UEw+nQ>U?gB3r|;q&~G{7y2;$(Nz484s;?FZuKz z`s3llBgRVTten0pDHEc~dU`I+ID*v7__(3;jPmWL;*+KPNV#SW2@9dK8b&>W>~iq{iDF5*Js@ww6-LJt%U=ES!_@23}B5VD&^Be0uhPQWJ{o z4Y=1`l9<^q1`R769F?TgTq5W6Sb<%wHxTJh!NDcpoQ}Fvh93E4CSLC~2GpjK^K&Mc z8;O9sRyA8V{Z&aDj&IK_NJ#nZ`TwYaiptPC;k(I%D`_O}wCec{iFZXTp9jJTBl}Im zD;aYvO-&5;w%qIYb1LDNs;g|6F6R$-ylnG`kYjNK#T3MmtnDzsz25s%)TL?J0}1CP z_`b(^^@Bd7UtPsbwgkY^4=GJ-e%5C6M^E~-DlL==T4WxFg!T(*5Za>H_ zfLBK#spWV>iM4~yQO3t7HZXTLx6BbN(lcVQ&I`ldvj+ni!y|0Kijp%;SI@rb?t6{R z)4P^QblxkIu^tp6>(y~1mzcA$2rD%Tv!ML-?L92o<}k*d?jQg$+(^2$Ij$#g&n&;B z$M>iW1z2%}Jz=-c6llk}Q@wyav6FslkAg7RAbbMLDe6kofpupr>%krQs!QK*mJ?|X zYlO-djhF%1$P8;{QzxQ=c&5#hVm<-s{@cmU^P3AP=Pe)pXUQ62-a1e!A1+FaMln2%l!Wm;{fp6cwVIBo{AL-y!I{<*(812@Ht@LGjd6_n# zET(e}ssN-IE76afI(qN?DEK^8)d z(hi{8;#Yp_XM^l^I7E_msh$lbwZj;w7_ZOjj{2HGJOh71^u1h8G*Vdfd>&gLHWWg= z$mC^RF(YDoodwryCMTOiDk?BlwI0eHVJogc4EeryEwzWrWBJ~EhqO$0gxKL8%+|wV zS5_h*_eq-CnP!{unhPI4*Kju8BxG~1C7(6dkjh%FQXO)%R=Wfe)zU8o@a18nAMVJ< zdu(WE9@B5x)&RnnSauO1_5CVcle?kbE;IV3lzWMCC$}KPgfyZE$hz0d6nnX<8f!R) zdo9ix;*#P{*CjDd@ulMGTd+)H1Dm$0mJ5{C6hWAeyrDzHkukNEr0@e^xdb`Bm=ijt zCb2sgdkLyzL+w&P{`5-iY=+jc(T=OJ?UQ~XK5=V=dE9xI>4^&Z)MOoXKrEPfpBQ#J zyIt#+yiIY#(qQ&(80W}=@DI$n&RVOvI|RM!R$Ms*!{=hFT?FUp*~z2ZB@5o{x%~HF z1M_L|fdVje%3h0B36B6~}Uq|`G@BG-$EPZ}!AU%WWid?Qd; z88uMNe*9`BjkrW^DJ}{eLPc-feUb)t_IN=NLNE$1zKu%j_2%XAy7E;Y%BBq+;3KX( zdm3+?G+mmWmjt}mc9D;bd8NX94PzuD0x&Qzz<3%4OS5|?nlP4qeVE0wt65ozwAcB% z81;||OtF}No}>iYf97tbqKTCac}6OGL%4qK;_Xn|ETY8L(jBeIF3$)UER~p+Ca}i$ zc7I#CPUC?lcI@d0`|Kr7$aEF%{lxnMg7>8|v-NJ(6;?z#by~M?@}*`7T2gVJs<)K* zot1VIF;j75J&ai-wb?29>fRn|^2NzdXejxJmwID-7p)y*G0v~ApP(tf>uyZUlAvPY zXKc*vStLRv*}jHKenuvfW^t(;$~!jm$3CXi7$-lY4W(8j+ROdtEr8kHl3shymziQY zN}7}Q)4XI|emY0I|0#dJ$Ux{;Jo!#eql~yR*IZ_G(D_0IE-?Vz&x`ZH7eHaXpVTO{ z6}@k7SVpX6I?4&fXf;4cxv~Cqia1Ifj>b0Fu{UivpVi_>>IjlG`n$}_*YU^Y>j)*- zns`t%%|H8)LNEP9h)i}s)3(4*{d*&vByMp$RqCh0WCD$;)F^rPT1oczEgVd6`YY$4 z!c9Q1K;2|H1#IQ$@lq=cb!zE-Ar0Ktg2ECDr(R}>-Iaex425?c3WSOT@LG2>UJyU@ zrc+}Ka|W+Ja9u^+6IAnlws_2I;2*E-P| z@W{WOnjC5uS`)f2EReK9GHu~D>p3N^MSQV++|B&@TOr#8*j~M>wdpoHdkaijuAW6r zVG%l1J9D3pHTI+a{)!HxHQx->P?8Kb+={+w1J zUl)0e76<_8E9hRM+kDfnVrnFt1CDcF1}%1rM%fJzn{wBuJ>{Ci@r}Q{{woUX%->LZVdITu zgai~iAFs>$$RuZR1Q3ut<<|G$IKI%OdG9^NIzIr(X*|N!c6=2wLyAkS+E8-gF;f9g zQkj+Hu@SpOr9j(|g|L1W8!v|RqX&*5_t>?h=omux|5&mUdv0y$#agr#`q&t=kIKI- z)!QZV^X%+t>AS#t1@t=+1tyl{=CEVB@NnJjzkqRa3!ZC!3wfr`bV1Iq!l-ql8NOKv z)!azB%Ghb1hQH{!t_sQ%MoynSRge=kI!^Y3esN9u^?AXrZb-UA`D$p`A~R}H12Tk+ z3!;tZUgz7zYGm+GE}NN+?cC{60+sCb>)rS~;`EK9@;TjgS|#VQkNkAK=%_ZAl8iee z{a0|RiX3wTucm}v+jn6dn*9I#KkO|?Wba-o-&hasmMQ3p7LC8ZV~4{^0JyKhtBCg> zJ`~2;8+6;{Z@rF9L-v}P&_JR34?dI8@Km^m5cBXoin4e8r2B~o_8|wR-V1YVDXSAd zW&wG5!_l{lN1X3S?zX`;;OfQ$?IEY44Q*ZNO2LfMh| z;ui0kg#T_+a^zl*1Wj2z8pmfUiEQ03BY43%hHCH9sYIkd&6yKhev=EA2 z`sEgXsb!x8-?M6Wb^qYtI^q{59cHpgG)?ZNR@QpL;4I%a9g)H~>O~Ww@uOw4Ts()3 z2??InG6tPwdiUt|G``V&+t?yXL94seLk~b-4U)-AF)pVLDXx=#$8Z*WtQMQXu`OQu zjBYO;)T5$%W>ubCudQ|6#D?|pKHdtbL+AWG>w#Vpq#L7^mGFR-6rW) znMuFJhHy~LY#vn7spl7RpRZ7ooLcfXxU_VDf ze|e=o=}Z_SkBYJ$Dpc?Arw0T3_wUCXl$UP?&ykun1@0m2IEgfH~t{ti4>X9ohW1{QF&D>pC77>=c;~0SRUG#tZ?Y|v;y5%uYJI49hdmk?^&A6S- zZy4?2Vhv;b^l^FiYJNCkJypBBzl-Nz*3I64(I_+UeV$=D{YdvQe$3$eqss}0!xyZ8 zrjcSPB!%b?Edc z5wg~kMWrg38{1xZt0P>HV9m)RI~`%&XGU#i9f=wiT>$A?ANK4RAUA?-VQ3t?nz*x9pAMaVLQ=Kcts}@OdJcF!8oIwo1XhtMCdJa|I%HByLSYyzVr+~lo<|JbBLcju@YXcWs$v`H!qXdRaf>@ z@IuFSmRCP8gM!8xeu6LAXDDsH#l#~IAI1gSr28B0R%Y9|V1u1MlIIMuPE}^lKaj>@ zJ}K-NmF$A*gU64*-TN#};le-%PyJwXTN-F;;H$Sk^aH*Usqsl+>PCZh*y7l8%zMH! zoG$OiJ_cJb-IcH$u4vBijjLoOdX>7mM|)GKd9pCb4m%oiurM;n>2f!|zegI?{EtS} zn>)aeuNGAP(s7qDd^t7tocc;Z)qZT8H)U+0-U|14OAqMk>iF`mdbh|_V)*Q>>8Zv_ ze*9kJSVl8(bMq$PqwRI9qs6^7QI{t2F_%||(XUzwOA? zWOE?0$oV&W09zSN$?V9)hBRUNw!LB<;wbX*w))yTdUO+*f8F+ShG5_}j>>Lr3YqOr zL@TfVOjWIT?n&giarJWc?;SgNL`Ho?mxI1QWPjOk!n0+F2t!hF0mSCaXDE&F#0$@z zzGcVmUWIw7M6#X;|G1OKFV>K<-Si&Vnef@R zENom0;3V?`U_<~c8pW*jmR;llG1%dib`qwD9$uhpZ4F~<7$iBvW59Ma&%Xb#LGVVj z73rqVEGqPjADKTLf=ZhF9qY~J1hbgl=pRI?a}-%F`D#-Q-JeY(L{<08MqilS?{%*# z2%k8i#+dTUc1inFs&F_I&FQbS8{1w;ezX_%G8zFjypX-$wEN8B1AkPWd#$vhaBs<1 zzN@==H!Rn3sEjPkerkGeoZEjeB%?$Zo<^)_Q1jK?wziy`E?N0@lH2#p^YB|E;mcjZqgkTJ<)NV`7U-|_>_ z=2J?VjI=!Izuq}0Bo7gtKmOs2Ig;>?%=8{q)XS?~Rvk1kcgS<{5>WF7M zDUrARMv8umn=v70^vlb7fcbS5|7X6IO-<;eJ>DmFNznX{p5Z11ksFHTkF}q(V}B0S z20xc}LCCVmZ+}jfhI){8V%E0Id;U8xpI72Dw`=K4KUSqgy3tUHVl1}gpk7R!=5`A+ g*dGP|uW{fGR4@)>Zp8X(|8vrL7Up18XW^UtPZ#o&y8r+H diff --git a/skins/example-classic-baseball/skin.json b/skins/example-classic-baseball/skin.json deleted file mode 100644 index ccec3071..00000000 --- a/skins/example-classic-baseball/skin.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "example-classic-baseball", - "name": "Example: Classic Baseball", - "version": "1.0.0", - "author": "LEDMatrix", - "description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.", - "skin_api_version": "1.0.0", - "targets": { - "sports": [ - "baseball" - ], - "sport_keys": [ - "mlb", - "milb" - ] - }, - "entry_point": "skin.py", - "class_name": "ClassicBaseballSkin", - "modes": [ - "live", - "recent", - "upcoming" - ], - "preview": "preview.png" -} diff --git a/skins/example-classic-baseball/skin.py b/skins/example-classic-baseball/skin.py deleted file mode 100644 index 6882a52c..00000000 --- a/skins/example-classic-baseball/skin.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Example: Classic Baseball — the reference skin. - -Shows the whole skin API surface on purpose: adaptive regions -(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit), -logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases -diamond), and per-user options (ctx.options). Everything is derived from -ctx and the game dict — a skin holds no state, does no I/O, and never -touches the display. - -Copy this directory to skins//, rename the class and the -manifest fields, and run: - - python scripts/validate_skin.py --skin -""" - -from src.adaptive_layout import LADDER_GRID, scoreboard_regions -from src.skin_system.skin_base import ScoreboardSkin, SkinContext - -DEFAULT_ACCENT = (255, 200, 0) - - -class ClassicBaseballSkin(ScoreboardSkin): - """Reference baseball skin: classic scorebug with bases/outs/count.""" - - def __init__(self, manifest: dict, options: dict): - super().__init__(manifest, options) - # Validate user options once at load time (fail fast, fall back - # gracefully) rather than surprising every render. - accent = self.options.get("accent_color", DEFAULT_ACCENT) - if (isinstance(accent, (list, tuple)) and len(accent) == 3 - and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)): - self._accent_color = tuple(accent) - else: - import logging - logging.getLogger(__name__).error( - "accent_color must be three 0-255 integers, got %r; using default", accent) - self._accent_color = DEFAULT_ACCENT - - # -- shared pieces ---------------------------------------------------- - - def _accent(self, ctx: SkinContext) -> tuple: - """Users can recolor the skin from config via skin_options.""" - return self._accent_color - - def _draw_card(self, ctx: SkinContext, game: dict, status: str, - center_lines: list, detail: str) -> None: - """The common card: logos left/right, status on top, the given - center content, detail along the bottom.""" - regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) - - ctx.draw_image(ctx.load_logo("away"), regions.away_slot, - cache_key=f"logo:{game.get('away_abbr')}") - ctx.draw_image(ctx.load_logo("home"), regions.home_slot, - cache_key=f"logo:{game.get('home_abbr')}") - - if status: - fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID) - ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx)) - - if center_lines: - rows = regions.score_area.split_v(*[1] * len(center_lines)) - for line, row in zip(center_lines, rows): - if line: - fit = ctx.layout.fit_text(line, row, LADDER_GRID) - ctx.draw_fit(fit, row) - - if detail: - fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID) - ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160)) - - def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None: - """Raw-PIL escape hatch: a bases diamond + out dots in the bottom - band, sized from the layout scale so it works on any panel.""" - size = ctx.layout.px(3, minimum=2) # half-diagonal of one base - gap = ctx.layout.px(1) - cx = ctx.width // 2 - cy = ctx.height - (size * 2) - 1 - - bases = game.get("bases_occupied") or [False, False, False] - # (dx, dy) per base: first (right), second (top), third (left) - offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)] - for occupied, (dx, dy) in zip(bases, offsets): - x, y = cx + dx, cy + dy - diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)] - if occupied: - ctx.draw.polygon(diamond, fill=self._accent(ctx)) - else: - ctx.draw.polygon(diamond, outline=(110, 110, 110)) - - outs = min(int(game.get("outs") or 0), 3) - r = max(1, size - 1) - for i in range(3): - x = cx + (i - 1) * (2 * r + 2 * gap) - y = ctx.height - r - 1 - dot = [x - r, y - r, x + r, y + r] - if i < outs: - ctx.draw.ellipse(dot, fill=(255, 255, 255)) - else: - ctx.draw.ellipse(dot, outline=(110, 110, 110)) - - # -- the three modes -------------------------------------------------- - - def render_live(self, ctx: SkinContext, game: dict) -> bool: - half = "▲" if game.get("inning_half") == "top" else "▼" - inning = game.get("inning") or "" - status = f"{half}{inning}" if inning else game.get("status_text", "") - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}" - - self._draw_card(ctx, game, status, [score], "") - self._draw_bases_and_outs(ctx, game) - - # Ball-strike count in the top-left corner, over the away logo. - fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID) - ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2), - color=(200, 200, 200)) - return True - - def render_recent(self, ctx: SkinContext, game: dict) -> bool: - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - self._draw_card(ctx, game, game.get("status_text", "Final"), - [score], game.get("series_summary", "")) - return True - - def render_upcoming(self, ctx: SkinContext, game: dict) -> bool: - matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}" - self._draw_card(ctx, game, game.get("game_date", ""), - [matchup, game.get("game_time", "")], - f"{game.get('away_record', '')} {game.get('home_record', '')}".strip()) - return True diff --git a/src/base_classes/api_extractors.py b/src/base_classes/api_extractors.py deleted file mode 100644 index 97155d86..00000000 --- a/src/base_classes/api_extractors.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -Abstract API Data Extraction Layer - -This module provides a pluggable system for extracting game data from different -sports APIs. Each sport can have its own extractor that handles sport-specific -fields and data structures. -""" - -from abc import ABC, abstractmethod -from typing import Dict, Optional -import logging -from datetime import datetime -import pytz - -class APIDataExtractor(ABC): - """Abstract base class for API data extraction.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - - @abstractmethod - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract common game details from raw API data.""" - - @abstractmethod - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract sport-specific fields (downs, innings, periods, etc.).""" - - def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - """Extract common game details that work across all sports.""" - if not game_event: - return None, None, None, None, None - - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - - # Parse game time - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - self.logger.warning(f"Could not parse game date: {game_date_str}") - - # Extract teams - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - return { - "game_event": game_event, - "competition": competition, - "status": status, - "situation": situation, - "start_time_utc": start_time_utc, - "home_team": home_team, - "away_team": away_team - }, home_team, away_team, status, situation - - except Exception as e: - self.logger.error(f"Error extracting common details: {e}") - return None, None, None, None, None - - -class ESPNFootballExtractor(APIDataExtractor): - """ESPN API extractor for football (NFL/NCAA).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract football game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting football game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract football-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "down": "", - "distance": "", - "possession": "", - "is_redzone": False, - "home_timeouts": 0, - "away_timeouts": 0, - "scoring_event": "" - } - - if situation and status["type"]["state"] == "in": - sport_fields.update({ - "down": situation.get("down", ""), - "distance": situation.get("distance", ""), - "possession": situation.get("possession", ""), - "is_redzone": situation.get("isRedZone", False), - "home_timeouts": situation.get("homeTimeouts", 0), - "away_timeouts": situation.get("awayTimeouts", 0) - }) - - # Detect scoring events - status_detail = status["type"].get("detail", "").lower() - if "touchdown" in status_detail or "field goal" in status_detail: - sport_fields["scoring_event"] = status_detail - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting football-specific fields: {e}") - return {} - - -class ESPNBaseballExtractor(APIDataExtractor): - """ESPN API extractor for baseball (MLB).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract baseball game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting baseball game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract baseball-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "inning": "", - "outs": 0, - "bases": "", - "strikes": 0, - "balls": 0, - "pitcher": "", - "batter": "" - } - - if situation and status["type"]["state"] == "in": - sport_fields.update({ - "inning": situation.get("inning", ""), - "outs": situation.get("outs", 0), - "bases": situation.get("bases", ""), - "strikes": situation.get("strikes", 0), - "balls": situation.get("balls", 0), - "pitcher": situation.get("pitcher", ""), - "batter": situation.get("batter", "") - }) - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting baseball-specific fields: {e}") - return {} - - -class ESPNHockeyExtractor(APIDataExtractor): - """ESPN API extractor for hockey (NHL/NCAA).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract hockey game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting hockey game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract hockey-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "period": "", - "period_text": "", - "power_play": False, - "penalties": "", - "shots_on_goal": {"home": 0, "away": 0} - } - - if situation and status["type"]["state"] == "in": - period = status.get("period", 0) - period_text = "" - if period == 1: - period_text = "P1" - elif period == 2: - period_text = "P2" - elif period == 3: - period_text = "P3" - elif period > 3: - period_text = f"OT{period-3}" - - sport_fields.update({ - "period": str(period), - "period_text": period_text, - "power_play": situation.get("isPowerPlay", False), - "penalties": situation.get("penalties", ""), - "shots_on_goal": { - "home": situation.get("homeShots", 0), - "away": situation.get("awayShots", 0) - } - }) - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting hockey-specific fields: {e}") - return {} - - -class SoccerAPIExtractor(APIDataExtractor): - """Generic extractor for soccer APIs (different structure than ESPN).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract soccer game details from various soccer APIs.""" - # This would need to be adapted based on the specific soccer API being used - # For now, return a basic structure - try: - return { - "id": game_event.get("id"), - "home_abbr": game_event.get("home_team", {}).get("abbreviation", ""), - "away_abbr": game_event.get("away_team", {}).get("abbreviation", ""), - "home_score": str(game_event.get("home_score", "0")), - "away_score": str(game_event.get("away_score", "0")), - "home_team_name": game_event.get("home_team", {}).get("name", ""), - "away_team_name": game_event.get("away_team", {}).get("name", ""), - "status_text": game_event.get("status", ""), - "is_live": game_event.get("is_live", False), - "is_final": game_event.get("is_final", False), - "is_upcoming": game_event.get("is_upcoming", False), - **self.get_sport_specific_fields(game_event) - } - except Exception as e: - self.logger.error(f"Error extracting soccer game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract soccer-specific fields.""" - try: - return { - "half": game_event.get("half", ""), - "stoppage_time": game_event.get("stoppage_time", ""), - "cards": { - "home_yellow": game_event.get("home_yellow_cards", 0), - "away_yellow": game_event.get("away_yellow_cards", 0), - "home_red": game_event.get("home_red_cards", 0), - "away_red": game_event.get("away_red_cards", 0) - }, - "possession": { - "home": game_event.get("home_possession", 0), - "away": game_event.get("away_possession", 0) - } - } - except Exception as e: - self.logger.error(f"Error extracting soccer-specific fields: {e}") - return {} - - -# Factory function removed - sport classes now instantiate extractors directly diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py deleted file mode 100644 index f36ca9db..00000000 --- a/src/base_classes/baseball.py +++ /dev/null @@ -1,692 +0,0 @@ -""" -Baseball Base Classes - -This module provides baseball-specific base classes that extend the core sports functionality -with baseball-specific logic for innings, outs, bases, strikes, balls, etc. -""" - -import logging -import time -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive, SportsRecent - - -class Baseball(SportsCore): - """Base class for baseball sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - # Baseball-specific configuration - self.show_innings = self.mode_config.get("show_innings", True) - self.show_outs = self.mode_config.get("show_outs", True) - self.show_bases = self.mode_config.get("show_bases", True) - self.show_count = self.mode_config.get("show_count", True) - self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False) - self.show_series_summary = self.mode_config.get("show_series_summary", False) - self.data_source = ESPNDataSource(logger) - self.sport = "baseball" - - def _is_baseball_game_live(self, game: Dict) -> bool: - """Check if a baseball game is currently live.""" - try: - # Check if game is marked as live - is_live = game.get("is_live", False) - if is_live: - return True - - # Check inning to determine if game is active - inning = game.get("inning", "") - if inning and inning != "Final": - return True - - return False - - except Exception as e: - self.logger.error(f"Error checking if baseball game is live: {e}") - return False - - def _get_baseball_game_status(self, game: Dict) -> str: - """Get baseball-specific game status.""" - try: - status = game.get("status_text", "") - inning = game.get("inning", "") - - if self._is_baseball_game_live(game): - if inning: - return f"Live - {inning}" - else: - return "Live" - elif game.get("is_final", False): - return "Final" - elif game.get("is_upcoming", False): - return "Upcoming" - else: - return status - - except Exception as e: - self.logger.error(f"Error getting baseball game status: {e}") - return "" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - # print(status["type"]["state"]) - # exit() - game_status = status["type"]["name"].lower() - status_state = status["type"]["state"].lower() - # Get team abbreviations - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - - # Check if this is a favorite team game - is_favorite_game = ( - home_abbr in self.favorite_teams or away_abbr in self.favorite_teams - ) - - # Log all teams found for debugging - self.logger.debug( - f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})" - ) - - # Only log detailed information for favorite teams - if is_favorite_game: - # Use the validated competition-level `status` here too. MiLB - # events carry no event-level one, so this debug line raised a - # KeyError and dropped the very games it was meant to help - # diagnose -- and only for favourites, which is the worst way - # for it to fail. - self.logger.debug(f"Full status data: {status}") - self.logger.debug(f"Status type: {game_status}, State: {status_state}") - self.logger.debug(f"Status detail: {status['type'].get('detail', '')}") - self.logger.debug( - f"Status shortDetail: {status['type'].get('shortDetail', '')}" - ) - series = game_event["competitions"][0].get("series", None) - series_summary = "" - if series: - series_summary = series.get("summary", "") - # Get game state information - if status_state == "in": - # For live games, get detailed state - # Use the competition-level `status` already validated by - # _extract_game_details_common. Real ESPN events duplicate - # status at the event top level, but MiLB events (synthesized - # from the MLB Stats API into an ESPN-like shape) populate - # only the competition-level one, so the top-level lookup - # raised a bare KeyError and dropped the event. - inning = status.get( - "period", 1 - ) # Get inning from status period - - # Get inning information from status - status_detail = status["type"].get("detail", "").lower() - status_short = status["type"].get("shortDetail", "").lower() - - if is_favorite_game: - self.logger.debug( - f"Raw status detail: {status['type'].get('detail')}" - ) - self.logger.debug( - f"Raw status short: {status['type'].get('shortDetail')}" - ) - - # Determine inning half from status information - inning_half = "top" # Default - - # Handle end of inning: next inning is top - if "end" in status_detail or "end" in status_short: - inning_half = "top" - inning = ( - status.get("period", 1) + 1 - ) # Use period and increment for next inning - if is_favorite_game: - self.logger.debug( - f"Detected end of inning. Setting to Top {inning}" - ) - # Handle middle of inning: next is bottom of current inning - elif "mid" in status_detail or "mid" in status_short: - inning_half = "bottom" - if is_favorite_game: - self.logger.debug( - f"Detected middle of inning. Setting to Bottom {inning}" - ) - # Handle bottom of inning - elif ( - "bottom" in status_detail - or "bot" in status_detail - or "bottom" in status_short - or "bot" in status_short - ): - inning_half = "bottom" - if is_favorite_game: - self.logger.debug(f"Detected bottom of inning: {inning}") - # Handle top of inning - elif "top" in status_detail or "top" in status_short: - inning_half = "top" - if is_favorite_game: - self.logger.debug(f"Detected top of inning: {inning}") - - if is_favorite_game: - self.logger.debug(f"Status detail: {status_detail}") - self.logger.debug(f"Status short: {status_short}") - self.logger.debug(f"Determined inning: {inning_half} {inning}") - - # Get count and bases from situation - situation = game_event["competitions"][0].get("situation", {}) - - if is_favorite_game: - self.logger.debug(f"Full situation data: {situation}") - - # Get count from the correct location in the API response - count = situation.get("count", {}) - balls = count.get("balls", 0) - strikes = count.get("strikes", 0) - outs = situation.get("outs", 0) - - # Add detailed logging for favorite team games - if is_favorite_game: - self.logger.debug(f"Full situation data: {situation}") - self.logger.debug(f"Count object: {count}") - self.logger.debug( - f"Raw count values - balls: {balls}, strikes: {strikes}" - ) - self.logger.debug(f"Raw outs value: {outs}") - - # Try alternative locations for count data - if balls == 0 and strikes == 0: - # First try the summary field - if "summary" in situation: - try: - count_summary = situation["summary"] - balls, strikes = map(int, count_summary.split("-")) - if is_favorite_game: - self.logger.debug( - f"Using summary count: {count_summary}" - ) - except (ValueError, AttributeError): - if is_favorite_game: - self.logger.debug("Could not parse summary count") - else: - # Check if count is directly in situation - balls = situation.get("balls", 0) - strikes = situation.get("strikes", 0) - if is_favorite_game: - self.logger.debug( - f"Using direct situation count: balls={balls}, strikes={strikes}" - ) - self.logger.debug( - f"Full situation keys: {list(situation.keys())}" - ) - - if is_favorite_game: - self.logger.debug(f"Final count: balls={balls}, strikes={strikes}") - - # Get base runners - bases_occupied = [ - situation.get("onFirst", False), - situation.get("onSecond", False), - situation.get("onThird", False), - ] - - if is_favorite_game: - self.logger.debug(f"Bases occupied: {bases_occupied}") - else: - # Default values for non-live games - inning = 1 - inning_half = "top" - balls = 0 - strikes = 0 - outs = 0 - bases_occupied = [False, False, False] - - details.update( - { - "status": game_status, - "status_state": status_state, - "inning": inning, - "inning_half": inning_half, - "balls": balls, - "strikes": strikes, - "outs": outs, - "bases_occupied": bases_occupied, - "start_time": game_event["date"], - "series_summary": series_summary, - } - ) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - if not self.show_series_summary: - return - - series_summary = game.get("series_summary", "") - bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time']) - height = bbox[3] - bbox[1] - shots_y = (self.display_height - height) // 2 - shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time']) - shots_x = (self.display_width - shots_width) // 2 - self._draw_text_with_outline( - draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time'] - ) - -class BaseballRecent(Baseball, SportsRecent): - """Base class for recent baseball games.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - self.display_series_summary(game, draw_overlay) - - -class BaseballLive(Baseball, SportsLive): - """Base class for live baseball games.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # self.current_game["bases_occupied"] = [ - # random.choice([True, False]) for _ in range(3) - # ] - # self.current_game["balls"] = random.choice([1, 2, 3]) - # self.current_game["strikes"] = random.choice([1, 2]) - # self.current_game["outs"] = random.choice([1, 2]) - if self.current_game["inning_half"] == "top": - self.current_game["inning_half"] = "bottom" - else: - self.current_game["inning_half"] = "top" - self.current_game["inning"] += 1 - self.current_game["balls"] = (self.current_game["balls"] + 1) % 4 - self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3 - self.current_game["outs"] = (self.current_game["outs"] + 1) % 3 - self.current_game["bases_occupied"] = [ - not b for b in self.current_game["bases_occupied"] - ] - if self.current_game["inning"] % 2 == 0: - self.current_game["home_score"] = str( - int(self.current_game["home_score"]) + 1 - ) - else: - self.current_game["away_score"] = str( - int(self.current_game["away_score"]) + 1 - ) - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Live Game Specific Elements --- - - # Define default text color - text_color = (255, 255, 255) - - # Draw Inning (Top Center) - inning_half = game["inning_half"] - inning_num = game["inning"] - if game["is_final"]: - inning_text = "FINAL" - else: - inning_half_indicator = ( - "▲" if game["inning_half"].lower() == "top" else "▼" - ) - inning_num = game["inning"] - inning_text = f"{inning_half_indicator}{inning_num}" - - inning_bbox = draw_overlay.textbbox( - (0, 0), inning_text, font=self.display_manager.font - ) - inning_width = inning_bbox[2] - inning_bbox[0] - inning_x = (self.display_width - inning_width) // 2 - inning_y = 1 # Position near top center - # draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font) - self._draw_text_with_outline( - draw_overlay, - inning_text, - (inning_x, inning_y), - self.display_manager.font, - ) - - # --- REVISED BASES AND OUTS DRAWING --- - bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd] - outs = game.get("outs", 0) - inning_half = game["inning_half"] - - # Define geometry - base_diamond_size = 7 - out_circle_diameter = 3 - out_vertical_spacing = 2 # Space between out circles - spacing_between_bases_outs = ( - 3 # Horizontal space between base cluster and out column - ) - base_vert_spacing = 1 # Internal vertical space in base cluster - base_horiz_spacing = 1 # Internal horizontal space in base cluster - - # Calculate cluster dimensions - base_cluster_height = ( - base_diamond_size + base_vert_spacing + base_diamond_size - ) - base_cluster_width = ( - base_diamond_size + base_horiz_spacing + base_diamond_size - ) - out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing - out_cluster_width = out_circle_diameter - - # Calculate overall start positions - overall_start_y = ( - inning_bbox[3] + 0 - ) # Start immediately below inning text (moved up 3 pixels) - - # Center the BASE cluster horizontally - bases_origin_x = (self.display_width - base_cluster_width) // 2 - - # Determine relative positions for outs based on inning half - if inning_half == "top": # Away batting, outs on left - outs_column_x = ( - bases_origin_x - spacing_between_bases_outs - out_cluster_width - ) - else: # Home batting, outs on right - outs_column_x = ( - bases_origin_x + base_cluster_width + spacing_between_bases_outs - ) - - # Calculate vertical alignment offset for outs column (center align with bases cluster) - outs_column_start_y = ( - overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) - ) - - # --- Draw Bases (Diamonds) --- - base_color_occupied = (255, 255, 255) - base_color_empty = (255, 255, 255) # Outline color - h_d = base_diamond_size // 2 - - # 2nd Base (Top center relative to bases_origin_x) - c2x = bases_origin_x + base_cluster_width // 2 - c2y = overall_start_y + h_d - poly2 = [ - (c2x, overall_start_y), - (c2x + h_d, c2y), - (c2x, c2y + h_d), - (c2x - h_d, c2y), - ] - if bases_occupied[1]: - draw_overlay.polygon(poly2, fill=base_color_occupied) - else: - draw_overlay.polygon(poly2, outline=base_color_empty) - - base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond - - # 3rd Base (Bottom left relative to bases_origin_x) - c3x = bases_origin_x + h_d - c3y = base_bottom_y + base_vert_spacing + h_d - poly3 = [ - (c3x, base_bottom_y + base_vert_spacing), - (c3x + h_d, c3y), - (c3x, c3y + h_d), - (c3x - h_d, c3y), - ] - if bases_occupied[2]: - draw_overlay.polygon(poly3, fill=base_color_occupied) - else: - draw_overlay.polygon(poly3, outline=base_color_empty) - - # 1st Base (Bottom right relative to bases_origin_x) - c1x = bases_origin_x + base_cluster_width - h_d - c1y = base_bottom_y + base_vert_spacing + h_d - poly1 = [ - (c1x, base_bottom_y + base_vert_spacing), - (c1x + h_d, c1y), - (c1x, c1y + h_d), - (c1x - h_d, c1y), - ] - if bases_occupied[0]: - draw_overlay.polygon(poly1, fill=base_color_occupied) - else: - draw_overlay.polygon(poly1, outline=base_color_empty) - - # --- Draw Outs (Vertical Circles) --- - circle_color_out = (255, 255, 255) - circle_color_empty_outline = (100, 100, 100) - - for i in range(3): - cx = outs_column_x - cy = outs_column_start_y + i * ( - out_circle_diameter + out_vertical_spacing - ) - coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] - if i < outs: - draw_overlay.ellipse(coords, fill=circle_color_out) - else: - draw_overlay.ellipse(coords, outline=circle_color_empty_outline) - - # --- Draw Balls-Strikes Count (BDF Font) --- - balls = game.get("balls", 0) - strikes = game.get("strikes", 0) - - # Add debug logging for count with cooldown - current_time = time.time() - if ( - game["home_abbr"] in self.favorite_teams - or game["away_abbr"] in self.favorite_teams - ) and current_time - self.last_count_log_time >= self.count_log_interval: - self.logger.debug(f"Displaying count: {balls}-{strikes}") - self.logger.debug( - f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}" - ) - self.last_count_log_time = current_time - - count_text = f"{balls}-{strikes}" - bdf_font = self.display_manager.calendar_font - bdf_font.set_char_size(height=7 * 64) # Set 7px height - count_text_width = self.display_manager.get_text_width(count_text, bdf_font) - - # Position below the base/out cluster - cluster_bottom_y = ( - overall_start_y + base_cluster_height - ) # Find the bottom of the taller part (bases) - count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster - - # Center horizontally within the BASE cluster width - count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 - - # Ensure draw object is set and draw text - self.display_manager.draw = draw_overlay - # self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font) - # Use _draw_text_with_outline for count text - # self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color) - - # Draw Balls-Strikes Count with outline using BDF font - # Define outline color (consistent with _draw_text_with_outline default) - outline_color_for_bdf = (0, 0, 0) - - # Draw outline - for dx_offset, dy_offset in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - self.display_manager._draw_bdf_text( - count_text, - count_x + dx_offset, - count_y + dy_offset, - color=outline_color_for_bdf, - font=bdf_font, - ) - - # Draw main text - self.display_manager._draw_bdf_text( - count_text, count_x, count_y, color=text_color, font=bdf_font - ) - - # Draw Team:Score at the bottom (matching main branch format) - score_font = self.display_manager.font # Use PressStart2P - outline_color = (0, 0, 0) - score_text_color = ( - 255, - 255, - 255, - ) # Use a specific name for score text color - - # Helper function for outlined text - def draw_bottom_outlined_text(x, y, text): - self._draw_text_with_outline( - draw_overlay, - text, - (x, y), - score_font, - fill=score_text_color, - outline_color=outline_color, - ) - - away_abbr = game["away_abbr"] - home_abbr = game["home_abbr"] - away_score_str = str(game["away_score"]) - home_score_str = str(game["home_score"]) - - away_text = f"{away_abbr}:{away_score_str}" - home_text = f"{home_abbr}:{home_score_str}" - - # Calculate Y position (bottom edge) - # Get font height (approximate or precise) - try: - font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] - except AttributeError: - font_height = 8 # Fallback for default font - score_y = ( - self.display_height - font_height - 2 - ) # 2 pixels padding from bottom - - # Away Team:Score (Bottom Left) - away_score_x = 2 # 2 pixels padding from left - draw_bottom_outlined_text(away_score_x, score_y, away_text) - - # Home Team:Score (Bottom Right) - home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - home_score_x = ( - self.display_width - home_text_width - 2 - ) # 2 pixels padding from right - draw_bottom_outlined_text(home_score_x, score_y, home_text) - - # Draw gambling odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Baseball game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/basketball.py b/src/base_classes/basketball.py deleted file mode 100644 index 72c4bf28..00000000 --- a/src/base_classes/basketball.py +++ /dev/null @@ -1,300 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - - -class Basketball(SportsCore): - """Base class for basketball sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "basketball" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 4: - period_text = f"Q{period}" # OT starts after Q4 - elif period > 4: - period_text = f"OT{period - 4}" # OT starts after Q4 - elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state - period_text = "HALF" - elif status["type"]["state"] == "post": - if period > 4 : period_text = "Final/OT" - else: period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - }) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - -class BasketballLive(Basketball, SportsLive): - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # For testing, we'll just update the clock to show it's working - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live Basketball game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Period/Quarter and Clock (Top center) - period_clock_text = ( - f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - ) - - status_width = draw_overlay.textlength( - period_clock_text, font=self.fonts["time"] - ) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline( - draw_overlay, - period_clock_text, - (status_x, status_y), - self.fonts["time"], - ) - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) - score_x = (self.display_width - score_width) // 2 - score_y = ( - self.display_height // 2 - ) - 3 # centered #from 14 # Position score higher - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] - ) - - # Draw odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get("away_abbr", "") - home_abbr = game.get("home_abbr", "") - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 1 - self.logger.debug( - f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" - ) - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = "" - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = "" - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get("away_record", "") - else: - away_text = "" - - if away_text: - away_record_x = 3 - self.logger.debug( - f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - away_text, - (away_record_x, record_y), - record_font, - ) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = "" - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = "" - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get("home_record", "") - else: - home_text = "" - - if home_text: - home_record_bbox = draw_overlay.textbbox( - (0, 0), home_text, font=record_font - ) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug( - f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - home_text, - (home_record_x, record_y), - record_font, - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Hockey game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/data_sources.py b/src/base_classes/data_sources.py deleted file mode 100644 index 80c6177a..00000000 --- a/src/base_classes/data_sources.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Pluggable Data Source Architecture - -This module provides abstract data sources that can be plugged into the sports system -to support different APIs and data providers. -""" - -from abc import ABC, abstractmethod -from typing import Dict, List -import requests -import logging -from datetime import datetime -from src.common.espn_dates import fetch_espn_scoreboard - -class DataSource(ABC): - """Abstract base class for data sources.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - self.session = requests.Session() - - # Configure retry strategy - from requests.adapters import HTTPAdapter - from urllib3.util.retry import Retry - - retry_strategy = Retry( - total=5, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - - @abstractmethod - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games for a sport/league.""" - - @abstractmethod - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule for a sport/league within date range.""" - - @abstractmethod - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings for a sport/league.""" - - def get_headers(self) -> Dict[str, str]: - """Get headers for API requests. - - The agent carries the project URL deliberately. Around 2026-08-04 ESPN - began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and - for browser-style strings — while accepting an agent that identifies - the client and links to it. An Accept header alone does not rescue the - bare form when the request goes out through requests. - """ - return { - 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', - 'Accept': 'application/json' - } - - -class ESPNDataSource(DataSource): - """ESPN API data source.""" - - def __init__(self, logger: logging.Logger): - super().__init__(logger) - self.base_url = "https://site.api.espn.com/apis/site/v2/sports" - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from ESPN API.""" - try: - now = datetime.now() - formatted_date = now.strftime("%Y%m%d") - url = f"{self.base_url}/{sport}/{league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, params={"dates": formatted_date, "limit": 1000}, - headers=self.get_headers(), timeout=15, logger=self.logger, - ) - events = data.get('events', []) - - # Filter for live games - live_events = [event for event in events - if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] - - self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") - return live_events - - except Exception as e: - self.logger.error(f"Error fetching live games from ESPN: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from ESPN API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/{sport}/{league}/scoreboard" - - params = { - 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", - "limit": 1000 - } - - data = fetch_espn_scoreboard( - self.session, url, params=params, - headers=self.get_headers(), timeout=15, logger=self.logger, - ) - events = data.get('events', []) - - self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") - return events - - except Exception as e: - self.logger.error(f"Error fetching schedule from ESPN: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings, or the poll for leagues that have one. - - Order matters and used to be wrong. College leagues publish a poll at - /rankings and a records table at /standings; professional leagues have - only /standings. The old code tried /standings first and fell back to - /rankings only on a 404 -- but college /standings answers 200, so the - fallback never fired and college rankings came back empty forever. - Nothing failed; the AP rank badge simply never appeared, and anything - else keyed off rankings quietly did nothing. - - A 200 that lacks the key is treated as a miss, so a league answering - both endpoints still ends up with whichever one actually carries a poll. - """ - league_name = (league or "").lower() - wants_poll = "college" in league_name or "ncaa" in league_name - endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"] - - for endpoint in endpoints: - url = f"{self.base_url}/{sport}/{league}/{endpoint}" - # Only the request is guarded. Inspecting the payload happens - # below, outside the handler, so that a bug in this method cannot - # be mistaken for an endpoint that failed -- that mistake would - # silently drop rankings for a league that has them, which is the - # exact failure this function was written to fix. - try: - response = self.session.get( - url, headers=self.get_headers(), timeout=15 - ) - response.raise_for_status() - data = response.json() - except (requests.RequestException, ValueError) as e: - status = getattr(getattr(e, "response", None), "status_code", None) - # Only a 404 is routine -- it is how a league says "no poll - # here". Everything else is worth an error, and `status is - # None` covers the ones that matter most: ConnectionError, - # Timeout, a body that would not parse. Silencing those left a - # board that could not reach ESPN with one debug line, and the - # ranked filter running on an empty table. - if status != 404: - self.logger.error( - f"Error fetching {endpoint} from ESPN for " - f"{sport}/{league}: {e}" - ) - continue - - if not isinstance(data, dict): - # A list or a bare string is not something the callers can - # read. Treat it as a miss so the other endpoint still gets a - # turn, but say so -- this means ESPN changed shape. - self.logger.error( - f"Unexpected {endpoint} payload for {sport}/{league}: " - f"got {type(data).__name__}, expected an object" - ) - continue - if endpoint == "rankings" and not data.get("rankings"): - continue - self.logger.debug(f"Fetched {endpoint} for {sport}/{league}") - return data - self.logger.debug( - f"Standings/rankings not available for {sport}/{league} from ESPN API" - ) - return {} - - -class MLBAPIDataSource(DataSource): - """MLB API data source.""" - - def __init__(self, logger: logging.Logger): - super().__init__(logger) - self.base_url = "https://statsapi.mlb.com/api/v1" - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from MLB API.""" - try: - url = f"{self.base_url}/schedule" - params = { - 'sportId': 1, # MLB - 'date': datetime.now().strftime('%Y-%m-%d'), - 'hydrate': 'game,team,venue,weather' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - games = data.get('dates', [{}])[0].get('games', []) - - # Filter for live games - live_games = [game for game in games - if game.get('status', {}).get('abstractGameState') == 'Live'] - - self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") - return live_games - - except Exception as e: - self.logger.error(f"Error fetching live games from MLB API: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from MLB API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/schedule" - - params = { - 'sportId': 1, # MLB - 'startDate': start_date.strftime('%Y-%m-%d'), - 'endDate': end_date.strftime('%Y-%m-%d'), - 'hydrate': 'game,team,venue' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - all_games = [] - for date_data in data.get('dates', []): - all_games.extend(date_data.get('games', [])) - - self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") - return all_games - - except Exception as e: - self.logger.error(f"Error fetching schedule from MLB API: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from MLB API.""" - try: - url = f"{self.base_url}/standings" - params = { - 'leagueId': 103, # American League - 'season': datetime.now().year, - 'standingsType': 'regularSeason' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug("Fetched standings from MLB API") - return data - - except Exception as e: - self.logger.error(f"Error fetching standings from MLB API: {e}") - return {} - - -class SoccerAPIDataSource(DataSource): - """Soccer API data source (generic structure).""" - - def __init__(self, logger: logging.Logger, api_key: str = None): - super().__init__(logger) - self.api_key = api_key - self.base_url = "https://api.football-data.org/v4" # Example API - - def get_headers(self) -> Dict[str, str]: - """Get headers with API key for soccer API.""" - headers = super().get_headers() - if self.api_key: - headers['X-Auth-Token'] = self.api_key - return headers - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from soccer API.""" - try: - # This would need to be adapted based on the specific soccer API - url = f"{self.base_url}/matches" - params = { - 'status': 'LIVE', - 'competition': league - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - matches = data.get('matches', []) - - self.logger.debug(f"Fetched {len(matches)} live games from soccer API") - return matches - - except Exception as e: - self.logger.error(f"Error fetching live games from soccer API: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from soccer API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/matches" - - params = { - 'competition': league, - 'dateFrom': start_date.strftime('%Y-%m-%d'), - 'dateTo': end_date.strftime('%Y-%m-%d') - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - matches = data.get('matches', []) - - self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API") - return matches - - except Exception as e: - self.logger.error(f"Error fetching schedule from soccer API: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from soccer API.""" - try: - url = f"{self.base_url}/competitions/{league}/standings" - response = self.session.get(url, headers=self.get_headers(), timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug("Fetched standings from soccer API") - return data - - except Exception as e: - self.logger.error(f"Error fetching standings from soccer API: {e}") - return {} - - -# Factory function removed - sport classes now instantiate data sources directly diff --git a/src/base_classes/football.py b/src/base_classes/football.py deleted file mode 100644 index 2974934f..00000000 --- a/src/base_classes/football.py +++ /dev/null @@ -1,387 +0,0 @@ -from typing import Dict, Any, Optional -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -import logging -from PIL import Image, ImageDraw, ImageFont -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive - -class Football(SportsCore): - """Base class for football sports with common functionality.""" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "football" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - details, home_team, away_team, status, situation = self._extract_game_details_common(game_event) - if details is None or home_team is None or away_team is None or status is None: - return - try: - competition = game_event["competitions"][0] - status = competition["status"] - - # --- Football Specific Details (Likely same for NFL/NCAAFB) --- - down_distance_text = "" - down_distance_text_long = "" - possession_indicator = None # Default to None - scoring_event = "" # Track scoring events - home_timeouts = 0 - away_timeouts = 0 - is_redzone = False - posession = None - - if situation and status["type"]["state"] == "in": - # down = situation.get("down") - down_distance_text = situation.get("shortDownDistanceText") - down_distance_text_long = situation.get("downDistanceText") - # distance = situation.get("distance") - - # Detect scoring events from status detail - status_detail = status["type"].get("detail", "").lower() - status_short = status["type"].get("shortDetail", "").lower() - is_redzone = situation.get("isRedZone") - posession = situation.get("possession") - - # Check for scoring events in status text - if any(keyword in status_detail for keyword in ["touchdown", "td"]): - scoring_event = "TOUCHDOWN" - elif any(keyword in status_detail for keyword in ["field goal", "fg"]): - scoring_event = "FIELD GOAL" - elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]): - scoring_event = "PAT" - elif any(keyword in status_short for keyword in ["touchdown", "td"]): - scoring_event = "TOUCHDOWN" - elif any(keyword in status_short for keyword in ["field goal", "fg"]): - scoring_event = "FIELD GOAL" - elif any(keyword in status_short for keyword in ["extra point", "pat"]): - scoring_event = "PAT" - - # Determine possession based on team ID - possession_team_id = situation.get("possession") - if possession_team_id: - if possession_team_id == home_team.get("id"): - possession_indicator = "home" - elif possession_team_id == away_team.get("id"): - possession_indicator = "away" - - home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified - away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified - - - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 4: - period_text = f"Q{period}" # OT starts after Q4 - elif period > 4: - period_text = f"OT{period - 4}" # OT starts after Q4 - elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state - period_text = "HALF" - elif status["type"]["state"] == "post": - if period > 4 : period_text = "Final/OT" - else: period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "home_timeouts": home_timeouts, - "away_timeouts": away_timeouts, - "down_distance_text": down_distance_text, # Added Down/Distance - "down_distance_text_long": down_distance_text_long, - "is_redzone": is_redzone, - "possession": posession, # ID of team with possession - "possession_indicator": possession_indicator, # Added for easy home/away check - "scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT) - }) - - # Basic validation (can be expanded) - if not details['home_abbr'] or not details['away_abbr']: - self.logger.warning(f"Missing team abbreviation in event: {details['id']}") - return None - - self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}") - - return details - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None - -class FootballLive(Football, SportsLive): - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - try: - minutes, seconds = map(int, self.current_game["clock"].split(':')) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - # Simulate end of quarter/game - if self.current_game["period"] < 4: # Q4 is period 4 - self.current_game["period"] += 1 - # Update period_text based on new period - if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1" - elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2" - elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3" - elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4" - # Reset clock for next quarter (e.g., 15:00) - minutes, seconds = 15, 0 - else: - # Simulate game end - self.current_game["is_live"] = False - self.current_game["is_final"] = True - self.current_game["period_text"] = "Final" - minutes, seconds = 0, 0 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Simulate down change occasionally - if seconds % 15 == 0: - self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}" - self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}" - - # Display update handled by main loop or explicit call if needed immediately - # self.display(force_clear=True) # Only if immediate update is desired here - - except ValueError: - self.logger.warning("Test mode: Could not parse clock") # Changed log prefix - # No actual display call here, let main loop handle it - - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 #adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # Period/Quarter and Clock (Top center) - period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - if game.get("is_halftime"): \ - period_clock_text = "Halftime" # Override for halftime - elif game.get("is_period_break"): - period_clock_text = game.get("status_text", "Period Break") - - status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time']) - - # Down & Distance or Scoring Event (Below Period/Clock) - scoring_event = game.get("scoring_event", "") - down_distance = game.get("down_distance_text", "") - if self.display_width > 128: - down_distance = game.get("down_distance_text_long", "") - - # Show scoring event if detected, otherwise show down & distance - if scoring_event and game.get("is_live"): - # Display scoring event with special formatting - event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail']) - event_x = (self.display_width - event_width) // 2 - event_y = (self.display_height) - 7 - - # Color coding for different scoring events - if scoring_event == "TOUCHDOWN": - event_color = (255, 215, 0) # Gold - elif scoring_event == "FIELD GOAL": - event_color = (0, 255, 0) # Green - elif scoring_event == "PAT": - event_color = (255, 165, 0) # Orange - else: - event_color = (255, 255, 255) # White - - self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color) - elif down_distance and game.get("is_live"): # Only show if live and available - dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail']) - dd_x = (self.display_width - dd_width) // 2 - dd_y = (self.display_height)- 7 # Top of D&D text - down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text - self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color) - - # Possession Indicator (small football icon) - possession = game.get("possession_indicator") - if possession: # Only draw if possession is known - ball_radius_x = 3 # Wider for football shape - ball_radius_y = 2 # Shorter for football shape - ball_color = (139, 69, 19) # Brown color for the football - lace_color = (255, 255, 255) # White for laces - - # Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall) - detail_font_height_approx = 6 - ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text - - possession_ball_padding = 3 # Pixels between D&D text and ball - - if possession == "away": - # Position ball to the left of D&D text - ball_x_center = dd_x - possession_ball_padding - ball_radius_x - elif possession == "home": - # Position ball to the right of D&D text - ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x - else: - ball_x_center = 0 # Should not happen / no indicator - - if ball_x_center > 0: # Draw if position is valid - # Draw the football shape (ellipse) - draw_overlay.ellipse( - (ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0 - ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1 - fill=ball_color, outline=(0,0,0) - ) - # Draw a simple horizontal lace - draw_overlay.line( - (ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center), - fill=lace_color, width=1 - ) - - # Timeouts (Bottom corners) - 3 small bars per team - timeout_bar_width = 4 - timeout_bar_height = 2 - timeout_spacing = 1 - timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge - - # Away Timeouts (Bottom Left) - away_timeouts_remaining = game.get("away_timeouts", 0) - for i in range(3): - to_x = 2 + i * (timeout_bar_width + timeout_spacing) - color = (255, 255, 255) if i < away_timeouts_remaining else (80, 80, 80) # White if available, gray if used - draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) - - # Home Timeouts (Bottom Right) - home_timeouts_remaining = game.get("home_timeouts", 0) - for i in range(3): - to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing) - color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used - draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 4 - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 3 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py deleted file mode 100644 index 419f09ec..00000000 --- a/src/base_classes/hockey.py +++ /dev/null @@ -1,380 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - - -class Hockey(SportsCore): - """Base class for hockey sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "hockey" - self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False) - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - competition = game_event["competitions"][0] - status = competition["status"] - powerplay = False - penalties = "" - # A competitor may legitimately arrive without a "statistics" - # array (pre-game feeds, and some in-progress ones). Reading it - # unguarded raised KeyError inside the generator and dropped the - # WHOLE event, discarding valid scores and status. Default to an - # empty list so the saves/shots figures fall back to 0 instead. - home_stats = home_team.get("statistics", []) - away_stats = away_team.get("statistics", []) - home_team_saves = next( - ( - int(c["displayValue"]) - for c in home_stats - if c.get("name") == "saves" - ), - 0, - ) - home_team_saves_per = next( - ( - float(c["displayValue"]) - for c in home_stats - if c.get("name") == "savePct" - ), - 0.0, - ) - away_team_saves = next( - ( - int(c["displayValue"]) - for c in away_stats - if c.get("name") == "saves" - ), - 0, - ) - away_team_saves_per = next( - ( - float(c["displayValue"]) - for c in away_stats - if c.get("name") == "savePct" - ), - 0.0, - ) - - home_shots = 0 - away_shots = 0 - if home_team_saves_per > 0: - away_shots = round(home_team_saves / home_team_saves_per) - if away_team_saves_per > 0: - home_shots = round(away_team_saves / away_team_saves_per) - if situation and status["type"]["state"] == "in": - # Detect scoring events from status detail - # status_detail = status["type"].get("detail", "") - powerplay = situation.get("isPowerPlay", False) - penalties = situation.get("penalties", "") - - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 3: - period_text = f"P{period}" # OT starts after Q4 - elif period > 3: - period_text = f"OT{period - 3}" # OT starts after Q4 - elif status["type"]["state"] == "post": - if period > 3: - period_text = "Final/OT" - else: - period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update( - { - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "power_play": powerplay, - "penalties": penalties, - "home_shots": home_shots, - "away_shots": away_shots, - } - ) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - -class HockeyLive(Hockey, SportsLive): - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # For testing, we'll just update the clock to show it's working - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Period/Quarter and Clock (Top center) - period_clock_text = ( - f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - ) - if game.get("is_period_break"): - period_clock_text = game.get("status_text", "Period Break") - - status_width = draw_overlay.textlength( - period_clock_text, font=self.fonts["time"] - ) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline( - draw_overlay, - period_clock_text, - (status_x, status_y), - self.fonts["time"], - ) - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) - score_x = (self.display_width - score_width) // 2 - score_y = ( - self.display_height // 2 - ) - 3 # centered #from 14 # Position score higher - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] - ) - - # Shots on Goal - if self.show_shots_on_goal: - shots_font = self.fonts.get('detail', ImageFont.load_default()) - home_shots = str(game.get("home_shots", "0")) - away_shots = str(game.get("away_shots", "0")) - shots_text = f"{away_shots} SHOTS {home_shots}" - shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font) - shots_height = shots_bbox[3] - shots_bbox[1] - shots_y = self.display_height - shots_height - 1 - shots_width = draw_overlay.textlength(shots_text, font=shots_font) - shots_x = (self.display_width - shots_width) // 2 - self._draw_text_with_outline( - draw_overlay, shots_text, (shots_x, shots_y), shots_font - ) - - # Draw odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get("away_abbr", "") - home_abbr = game.get("home_abbr", "") - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 1 - self.logger.debug( - f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" - ) - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = "" - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = "" - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get("away_record", "") - else: - away_text = "" - - if away_text: - away_record_x = 3 - self.logger.debug( - f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - away_text, - (away_record_x, record_y), - record_font, - ) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = "" - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = "" - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get("home_record", "") - else: - home_text = "" - - if home_text: - home_record_bbox = draw_overlay.textbbox( - (0, 0), home_text, font=record_font - ) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug( - f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - home_text, - (home_record_x, record_y), - record_font, - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Hockey game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/sports/__init__.py b/src/base_classes/sports/__init__.py deleted file mode 100644 index a6def39f..00000000 --- a/src/base_classes/sports/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Sports scoreboard base classes. - -Formerly the single module ``src/base_classes/sports.py``; now a package so -capabilities can be composed instead of accumulating in one class. See -docs/SPORTS_UNIFICATION.md for the architecture. The import path is -unchanged: ``from src.base_classes.sports import SportsCore`` still works. -""" - -from .core import SportsCore -from .modes import SportsLive, SportsRecent, SportsUpcoming - -__all__ = [ - "SportsCore", - "SportsUpcoming", - "SportsRecent", - "SportsLive", -] diff --git a/src/base_classes/sports/capabilities/__init__.py b/src/base_classes/sports/capabilities/__init__.py deleted file mode 100644 index e084ad7c..00000000 --- a/src/base_classes/sports/capabilities/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Opt-in capabilities for the sports scoreboards. - -Each module here is a feature that only *some* sports want. They are composed -by inheritance (mixins) or selected by name (strategies) — never enabled by an -``if self._enabled:`` branch inside the base classes. - -The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not -inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not -in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in. - -See ``docs/SPORTS_UNIFICATION.md`` for the full rationale. -""" - -from .celebrations import CelebrationMixin -from .rotation import ( - RotationStrategy, - SimpleRotation, - SmoothWeightedRotation, - WeightedCycleRotation, - get_rotation_strategy, - register_rotation_strategy, -) - -__all__ = [ - "CelebrationMixin", - "RotationStrategy", - "SimpleRotation", - "SmoothWeightedRotation", - "WeightedCycleRotation", - "get_rotation_strategy", - "register_rotation_strategy", -] diff --git a/src/base_classes/sports/capabilities/celebrations.py b/src/base_classes/sports/capabilities/celebrations.py deleted file mode 100644 index 49d371cd..00000000 --- a/src/base_classes/sports/capabilities/celebrations.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Score / win celebration takeover — an opt-in capability. - -Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other -five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the -five that do not opt in have none of this code in their MRO: a bug here cannot -reach hockey, and hockey's config never grows keys it ignores. - -Usage — mix in *before* the mode class so its ``display`` runs first:: - - class SoccerLive(CelebrationMixin, SportsLive): - def score_phrase(self, points, team_abbr): - return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!")) - -The two lineages spelled this differently (``_check_for_goal`` / -``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` / -``celebrate_opponent_scores`` in football) but the bodies were identical apart -from three things, each of which is a seam here rather than a branch: - -* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN" - from the points delta and soccer uses to say "GOOOOAAALLL"; -* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football - where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where - two quick goals are two real events; -* **team identity** — matching goes through ``_favorite_key``, so nrl can match - on team id (its abbreviations are ambiguous) without core knowing why. - -The config keys are read under both spellings, so a plugin adopting the mixin -keeps working with the ``*_goals`` keys already in its published schema. -""" - -from __future__ import annotations - -import re -import time -from typing import Any, Dict, List, Optional - -from PIL import Image, ImageDraw - - -class CelebrationMixin: - """Full-screen takeover when a tracked team scores or wins.""" - - #: Collapse increments that land while a celebration is already on screen - #: into that one celebration. True for sports where a single scoring play - #: arrives as more than one score update (football: touchdown +6, then the - #: extra point +1). False where consecutive increments are distinct events — - #: suppressing there would swallow a real goal. - COALESCE_SCORING_SEQUENCE = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - mode_config = getattr(self, "mode_config", {}) or {} - self.celebration_enabled = mode_config.get("celebration_enabled", True) - # Coerced and floored at init: this value is compared numerically on the - # display path, where a string from a hand-edited config would raise - # TypeError outside any try block, and a zero or negative value would - # arm a celebration that can never render. - raw_duration = mode_config.get("celebration_duration", 8) - try: - self.celebration_duration = max(1.0, float(raw_duration)) - except (TypeError, ValueError): - self.logger.warning( - "[Celebrations] Unusable celebration_duration %r; using 8s. " - "Set a positive number of seconds.", - raw_duration, - ) - self.celebration_duration = 8.0 - # Both spellings: the soccer lineage ships `celebrate_opponent_goals`, - # football ships `celebrate_opponent_scores`. Whichever the plugin's - # schema declares is the one its users have set. - self.celebrate_opponent_scores = mode_config.get( - "celebrate_opponent_scores", - mode_config.get("celebrate_opponent_goals", False), - ) - # Per-game score baselines: {game_id: {"away": int, "home": int}} - self._score_baselines: Dict[str, Dict[str, int]] = {} - # The active celebration (a game *snapshot*, so a win survives the game - # leaving live_games) or None. See _start_celebration for the shape. - self.active_celebration: Optional[Dict[str, Any]] = None - - # ------------------------------------------------------------------ - # Override points - # ------------------------------------------------------------------ - - def score_phrase(self, points: int, team_abbr: str) -> str: - """The wording for a score celebration. - - ``points`` is the score delta that triggered it, which sports with - variable-value scores use to name the play. The default is deliberately - sport-neutral; every celebrating plugin overrides it. - """ - return f"{team_abbr} SCORES!" - - def win_phrase(self, team_abbr: str) -> str: - """The wording for a win celebration.""" - return f"{team_abbr} WINS!" - - def _is_favorite(self, key: Optional[str]) -> bool: - """Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite.""" - return bool(self.favorite_teams) and key in self.favorite_teams - - # ------------------------------------------------------------------ - # Detection - # ------------------------------------------------------------------ - - @staticmethod - def _score_to_int(score) -> Optional[int]: - """Coerce an ESPN score value (str / int / dict) to an int, or None.""" - try: - if score is None: - return None - if isinstance(score, str): - s = score.strip() - if not s: - return None - try: - return int(float(s)) - except ValueError: - numbers = re.findall(r"\d+", s) - return int(numbers[0]) if numbers else None - if isinstance(score, dict): - return int(float(score.get("value", score.get("displayValue", 0)))) - return int(float(score)) - except (ValueError, TypeError): - return None - - def _should_celebrate_for(self, game: Dict, side: str) -> bool: - """Whether a score by ``side`` in ``game`` should trigger a celebration.""" - if self._is_favorite(self._favorite_key(game, side)): - return True - if not self.favorite_teams: - # No favorites configured: the user opted to show this game, so - # celebrate any score in it. - return True - # Favorites exist but this team isn't one -> it's the opponent. - return self.celebrate_opponent_scores - - def prune_score_baselines(self, live_games: List[Dict]) -> None: - """Drop baselines for games no longer live. - - Only :meth:`_check_for_win` removes entries, and it only fires for games - seen to go final. A game that vanishes from the live list any other way - — postponed, dropped by the feed, or simply still live when the board - restarts — leaves its baseline behind forever, so on a board that runs - all season the dict grows without bound. - - Call this from ``update()`` with the current live set, alongside the - equivalent pruning in :meth:`SmoothWeightedRotation.next_game`. - """ - live_ids = {g.get("id") for g in live_games} - self._score_baselines = { - gid: baseline - for gid, baseline in self._score_baselines.items() - if gid in live_ids - } - - def has_active_celebration(self) -> bool: - """True while a celebration is within its display window.""" - celebration = self.active_celebration - return bool(celebration) and ( - time.time() - celebration["started_at"] < self.celebration_duration - ) - - def _check_for_score(self, game: Dict) -> None: - """Compare a live game's score against its baseline and arm a - celebration when a celebratable team's score increases.""" - if not self.celebration_enabled: - return - game_id = game.get("id") - if not game_id: - return - away = self._score_to_int(game.get("away_score")) - home = self._score_to_int(game.get("home_score")) - if away is None or home is None: - return - - baseline = self._score_baselines.get(game_id) - # Always refresh the baseline: a first sighting must never celebrate (a - # game already in progress at boot would false-fire), and a decrement - # (VAR, a correction) just re-bases silently. - self._score_baselines[game_id] = {"away": away, "home": home} - if baseline is None: - return - - away_delta = away - baseline["away"] - home_delta = home - baseline["home"] - if away_delta <= 0 and home_delta <= 0: - return - - # One takeover per scoring sequence, where the sport has such a thing. - # The baseline is already advanced above, so nothing re-fires later. - if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration(): - return - - scored_side = None - points = 0 - if away_delta > 0 and self._should_celebrate_for(game, "away"): - scored_side, points = "away", away_delta - if scored_side is None and home_delta > 0 and self._should_celebrate_for( - game, "home" - ): - scored_side, points = "home", home_delta - if scored_side is None: - return - - self._start_celebration( - game, - "score", - scored_side=scored_side, - team_abbr=game.get(f"{scored_side}_abbr", ""), - away_score=away, - home_score=home, - points=points, - ) - - def _check_for_win(self, game: Dict) -> None: - """When a game we were tracking live goes final, arm a win celebration - if a favorite won. Fires at most once per game.""" - if not self.celebration_enabled: - return - game_id = game.get("id") - if not game_id: - return - # Only celebrate wins for games we actually watched go live: one seen - # for the first time already-final (the board started after full time) - # has no baseline and must not fire. - if game_id not in self._score_baselines: - return - # Consume the baseline so this can only fire once. - self._score_baselines.pop(game_id, None) - - away = self._score_to_int(game.get("away_score")) - home = self._score_to_int(game.get("home_score")) - if away is None or home is None: - return - - if away > home: - winner_side = "away" - elif home > away: - winner_side = "home" - else: - return # draw -> no win celebration - - # Wins are gated strictly on favorites: every game ends, so the - # "no favorites -> celebrate all" score fallback would be far too noisy. - if not self._is_favorite(self._favorite_key(game, winner_side)): - return - - self._start_celebration( - game, - "win", - scored_side=winner_side, - team_abbr=game.get(f"{winner_side}_abbr", ""), - away_score=away, - home_score=home, - ) - - def _start_celebration( - self, - game: Dict, - kind: str, - scored_side: str, - team_abbr: str, - away_score: int, - home_score: int, - points: int = 0, - ) -> None: - """Arm a celebration. ``scored_side`` ('away'/'home') is the side whose - score digit gets highlighted.""" - phrase = ( - self.win_phrase(team_abbr) - if kind == "win" - else self.score_phrase(points, team_abbr) - ) - - self.active_celebration = { - "kind": kind, - "game": dict(game), # snapshot: survives the game leaving live_games - "scored_side": scored_side, - "team_abbr": team_abbr, - "away_score": away_score, - "home_score": home_score, - "started_at": time.time(), - "phrase": phrase, - } - # Pin focus to the involved game so the post-celebration scorebug - # resumes on it. - self.current_game = dict(game) - self.logger.info( - f"[Celebrations] {kind} armed: {phrase} " - f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]" - ) - - # ------------------------------------------------------------------ - # Rendering - # ------------------------------------------------------------------ - - def _fit_font(self, draw, text: str, max_width: int, fonts: List): - """The first font whose rendered ``text`` fits ``max_width``, falling - back to the last (smallest) font.""" - for font in fonts: - if draw.textlength(text, font=font) <= max_width - 2: - return font - return fonts[-1] - - def _draw_celebration_layout( - self, celebration: Dict, force_clear: bool = False - ) -> None: - """Render the full-screen score/win takeover.""" - if force_clear: - self.display_manager.clear() - - display_width = ( - self.display_manager.matrix.width - if hasattr(self.display_manager, "matrix") and self.display_manager.matrix - else self.display_width - ) - display_height = ( - self.display_manager.matrix.height - if hasattr(self.display_manager, "matrix") and self.display_manager.matrix - else self.display_height - ) - - elapsed = time.time() - celebration["started_at"] - game = celebration["game"] - - # Background: a brief color flash for the first ~1.2s, then black. - bg = (0, 0, 0, 255) - if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0: - bg = (12, 12, 48, 255) - main_img = Image.new("RGBA", (display_width, display_height), bg) - overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) - draw = ImageDraw.Draw(overlay) - - # Logos at the edges (best-effort: a logo failure must not blank the - # celebration). - try: - center_y = display_height // 2 - home_logo = self._load_and_resize_logo( - game.get("home_id"), game.get("home_abbr"), - game.get("home_logo_path"), game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game.get("away_id"), game.get("away_abbr"), - game.get("away_logo_path"), game.get("away_logo_url"), - ) - if home_logo: - main_img.paste( - home_logo, - (display_width - home_logo.width + 2, center_y - home_logo.height // 2), - home_logo, - ) - if away_logo: - main_img.paste( - away_logo, (-2, center_y - away_logo.height // 2), away_logo - ) - except Exception as e: - self.logger.debug(f"[Celebrations] Logo load failed: {e}") - - # Phrase across the top, shrunk to fit the panel width. - phrase = celebration["phrase"] - phrase_font = self._fit_font( - draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]] - ) - phrase_width = draw.textlength(phrase, font=phrase_font) - self._draw_text_with_outline( - draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font - ) - - # Score centered low, with the scoring/winning side's digit pulsing in a - # highlight color so the change reads at a glance. - away_text = str(celebration["away_score"]) - home_text = str(celebration["home_score"]) - score_font = self.fonts["score"] - segments = [ - (away_text, celebration["scored_side"] == "away"), - ("-", False), - (home_text, celebration["scored_side"] == "home"), - ] - total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments) - highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0) - x = (display_width - total_width) // 2 - y = display_height - 14 - for seg, is_highlight in segments: - color = highlight if is_highlight else (255, 255, 255) - self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color) - x += draw.textlength(seg, font=score_font) - - main_img = Image.alpha_composite(main_img, overlay).convert("RGB") - self.display_manager.image = main_img - self.display_manager.update_display() - - def display(self, force_clear: bool = False) -> bool: - """Render an active celebration as a full-screen takeover; otherwise - defer to the normal live scorebug.""" - if not self.is_enabled: - return False - celebration = self.active_celebration - if celebration: - if self.has_active_celebration(): - try: - self._draw_celebration_layout(celebration, force_clear) - return True - except Exception as e: - self.logger.error( - f"[Celebrations] Error drawing celebration: {e}", exc_info=True - ) - # Disarm rather than retry: the same render would fail on - # every frame for the rest of the window, logging a - # traceback each time and leaving the scorebug off screen. - self.active_celebration = None - self.last_game_switch = time.time() - else: - self.active_celebration = None - # Reset the dwell so the scorebug resumes on the scoring/winning - # game for a full duration before rotation can move on. - self.last_game_switch = time.time() - return super().display(force_clear) diff --git a/src/base_classes/sports/capabilities/rotation.py b/src/base_classes/sports/capabilities/rotation.py deleted file mode 100644 index 4135ca9e..00000000 --- a/src/base_classes/sports/capabilities/rotation.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Live-rotation strategies — which live game to show next. - -The nine plugin copies grew three spellings of this, and the survey behind -``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted -Round-Robin algorithm in two shapes: - -* an **incremental picker** that holds weight state across calls and answers - "what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and -* a **precomputed cycle** that returns a full list of game ids up front - (football / baseball / basketball's ``_build_weighted_schedule`` and hockey's - ``_build_rotation_schedule``, which differ only in loop shape). - -They agree *within* a cycle — SWRR is deterministic — and differ only at cycle -boundaries, where the incremental form has no seam and the precomputed form -restarts. That is a real behavioral difference, so core ships both rather than -declaring a winner, and a plugin picks one by name: - - self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight) - -Core never learns which sport is asking. A plugin with a genuinely novel -ordering registers its own strategy instead of core growing a branch:: - - register_rotation_strategy("my-order", MyRotation) -""" - -from __future__ import annotations - -from typing import Callable, Dict, List, Optional, Type - - -def _game_id(game: Dict) -> Optional[str]: - """The rotation key for a game, or None if it has no usable id.""" - return game.get("id") - - -class RotationStrategy: - """Base class for live-rotation ordering. - - Subclasses implement :meth:`schedule`; :meth:`next_game` has a working - default derived from it. Strategies whose natural shape is incremental - override :meth:`next_game` instead and derive :meth:`schedule`. - - :param weight_for: callable mapping a game dict to a positive integer - weight — how many turns it gets per turn of a weight-1 game. Supplied by - the host so the *favorites* policy stays with the plugin and this module - stays free of any notion of what a favorite is. Defaults to equal - weights, which makes every strategy a plain round robin. - """ - - #: Name this strategy is registered under. Set by :func:`register_rotation_strategy`. - name: str = "" - - #: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each - #: step scans every game, so an unbounded weight — a misread config field, - #: say — would spin the display thread for an unbounded time. On a Pi that - #: stalls rendering outright, so the bound is clamped like the floor is. - MAX_WEIGHT = 16 - - def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): - self._weight_for = weight_for or (lambda game: 1) - - def weights(self, games: List[Dict]) -> Dict[str, int]: - """``{game_id: weight}`` for games that have an id, in ``games`` order. - - A weight below 1 is clamped up: a zero or negative weight would starve - a game out of the rotation entirely, which no caller means to express - and which would make ``total_weight`` collapse. It is clamped down at - :attr:`MAX_WEIGHT` for the reason documented there. - """ - weights: Dict[str, int] = {} - for game in games: - gid = _game_id(game) - if gid is None: - continue - try: - weight = int(self._weight_for(game)) - except (TypeError, ValueError): - weight = 1 - weights[gid] = min(self.MAX_WEIGHT, max(1, weight)) - return weights - - def schedule(self, games: List[Dict]) -> List[str]: - """Game ids in display order for one cycle. Ids may repeat.""" - raise NotImplementedError - - def next_game(self, games: List[Dict]) -> Optional[Dict]: - """The next game to display, or None when there is nothing to show.""" - order = self.schedule(games) - if not order: - return None - by_id = {gid: g for g in games if (gid := _game_id(g)) is not None} - return by_id.get(order[0]) - - def reset(self) -> None: - """Drop any accumulated state. Stateless strategies need do nothing.""" - - -class SimpleRotation(RotationStrategy): - """Plain round robin: every live game once per cycle, weights ignored. - - The fallback for a plugin that wants strictly even rotation regardless of - favorites. - """ - - def schedule(self, games: List[Dict]) -> List[str]: - return [gid for g in games if (gid := _game_id(g)) is not None] - - -class WeightedCycleRotation(RotationStrategy): - """Precomputed SWRR cycle — the football / baseball / basketball / hockey shape. - - Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly - rather than clumped, highest weight scheduled first. When no game carries a - boost the cycle degenerates to a single pass in ``games`` order, which is - exactly the plain round robin it replaced. - """ - - def schedule(self, games: List[Dict]) -> List[str]: - weights = self.weights(games) - if not weights: - return [] - total_weight = sum(weights.values()) - if total_weight <= len(weights): - # No boost in effect — plain order, one pass. (Also the guard that - # keeps the loop below from being O(total_weight) for nothing.) - return list(weights) - - current = {gid: 0 for gid in weights} - order: List[str] = [] - for _ in range(total_weight): - for gid, weight in weights.items(): - current[gid] += weight - picked = max(current, key=lambda gid: current[gid]) - current[picked] -= total_weight - order.append(picked) - return order - - -class SmoothWeightedRotation(RotationStrategy): - """Incremental SWRR — the afl / nrl / soccer shape. - - Weight state persists across calls, so there is no fixed-length cycle and - therefore no clustering seam at a cycle boundary. A game seen for the first - time starts at weight 0 and receives its full weight on the next call, so a - favorite's game that has just gone live naturally wins the first pick after - it appears — "queued first on refresh" without a special-cased branch. - - State for games no longer live is dropped on each call, so a long-running - board does not accumulate entries for finished games. - """ - - def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): - super().__init__(weight_for) - self._current: Dict[str, int] = {} - - def reset(self) -> None: - self._current = {} - - def next_game(self, games: List[Dict]) -> Optional[Dict]: - if not games: - return None - weights = self.weights(games) - if not weights: - return None - - # Keep state only for games still live. - self._current = { - gid: value for gid, value in self._current.items() if gid in weights - } - for gid, weight in weights.items(): - self._current[gid] = self._current.get(gid, 0) + weight - - total_weight = sum(weights.values()) - # Iterate in `games` order so ties break toward the feed's ordering, - # which is what the plugin copies did and what makes the no-boost case - # identical to a plain round robin. - ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights] - best = max(ids_in_order, key=lambda gid: self._current[gid]) - self._current[best] -= total_weight - return next(g for g in games if _game_id(g) == best) - - def schedule(self, games: List[Dict]) -> List[str]: - """One cycle's worth of picks, without disturbing live state. - - Derived by running the picker forward on a copy, so the returned order - is exactly what repeated :meth:`next_game` calls would produce from the - current state — callers can use it to preview or log the rotation - without perturbing it. - """ - weights = self.weights(games) - if not weights: - return [] - # type(self), not this class: a subclass that overrides next_game must - # be previewed through its own ordering, or the returned order is not - # the one repeated next_game calls would produce — which is exactly - # what this method promises. - preview = type(self)(self._weight_for) - preview._current = dict(self._current) - order: List[str] = [] - for _ in range(sum(weights.values())): - picked = preview.next_game(games) - if picked is None: - break - order.append(_game_id(picked)) - return order - - -_REGISTRY: Dict[str, Type[RotationStrategy]] = {} - - -def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None: - """Register a rotation strategy under ``name``. - - When a plugin needs an ordering that core does not ship, it registers its - own here instead of core growing a sport-specific branch. Re-registering a - name replaces it, so a plugin may also override a built-in for itself. - """ - if not name: - raise ValueError("rotation strategy name must be a non-empty string") - # Fail at registration, not at the first schedule() call several frames - # later, where the cause is no longer on the stack. - if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)): - raise TypeError( - f"rotation strategy {name!r} must be a RotationStrategy subclass, " - f"got {factory!r}" - ) - factory.name = name - _REGISTRY[name] = factory - - -def get_rotation_strategy( - name: str, weight_for: Optional[Callable[[Dict], int]] = None -) -> RotationStrategy: - """Build the strategy registered under ``name``. - - Falls back to ``"simple"`` for an unknown name rather than raising: the name - arrives from user config, and a typo should cost the boost, not the - scoreboard. - """ - factory = _REGISTRY.get(name) or _REGISTRY["simple"] - return factory(weight_for=weight_for) - - -register_rotation_strategy("simple", SimpleRotation) -register_rotation_strategy("weighted", WeightedCycleRotation) -register_rotation_strategy("swrr", SmoothWeightedRotation) diff --git a/src/base_classes/sports/core.py b/src/base_classes/sports/core.py deleted file mode 100644 index c4969386..00000000 --- a/src/base_classes/sports/core.py +++ /dev/null @@ -1,1148 +0,0 @@ -"""SportsCore — the shared fetch/cache/config/render base for the sports -scoreboards. Split out of the former ``src/base_classes/sports.py``; see -docs/SPORTS_UNIFICATION.md for the layering. -""" - -import logging -import os -import tempfile -import time -from abc import ABC, abstractmethod -from collections import OrderedDict -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import pytz -from src.common.espn_dates import fetch_espn_scoreboard -import requests -from PIL import Image, ImageDraw, ImageFont -from src.common.font_layout import load_truetype -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -from src.background_data_service import get_background_service - -# Import new architecture components (individual classes will import what they need) -from src.base_classes.api_extractors import APIDataExtractor -from src.base_classes.data_sources import DataSource -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager -from src.dynamic_team_resolver import DynamicTeamResolver -from src.logo_downloader import LogoDownloader, download_missing_logo -try: - from src.base_odds_manager import BaseOddsManager as OddsManager -except ImportError: - OddsManager = None - -# The core install root, resolved from this module's own location so nothing -# here depends on the process working directory. -# -# The index is the number of directories between this file and the root: -# src/base_classes/sports/core.py -> sports -> base_classes -> src -> root. -# It is defined ONCE, here, because hand-counting it at each use site is -# exactly what broke when this module moved from src/base_classes/sports.py -# into the package (the old depth of 2 silently started resolving to src/). -# If this module ever moves again, this is the single line to update. -_INSTALL_ROOT = Path(__file__).resolve().parents[3] - -# Shared element-style resolver. Core always ships src.element_style, so the -# guard is never taken here — it is kept because this module's methods are -# back-copied verbatim into the plugins' bundled `sports.py`, where older -# cores genuinely lack the module (the plugins fall back to the classic -# inline config read below). -try: - from src.element_style import ElementStyleResolver, defaults_from_schema_file - STYLE_AVAILABLE = True -except ImportError: # pragma: no cover - core always ships element_style - STYLE_AVAILABLE = False - - -# --- Font catalog delegation ------------------------------------------------- -# The plugin copies each carried their own alias table mapping font *family* -# names ("press_start") to filenames. That table is a duplicate of the core -# FontManager's `common_fonts` catalog, so resolve through the catalog instead -# and let the two never drift apart. Same lazy module-level shape as -# skin_runtime._get_font_manager / base_plugin._fallback_font_manager: a -# SportsCore host has no plugin_manager to borrow a FontManager from. - -_shared_font_catalog: Optional[Any] = None - - -def _font_catalog() -> Any: - """Shared read-only FontManager used purely as a font-name catalog.""" - global _shared_font_catalog - if _shared_font_catalog is None: - from src.font_manager import FontManager - _shared_font_catalog = FontManager({}) - return _shared_font_catalog - - -def _resolve_font_family_alias(font_name: str) -> str: - """Resolve a font family alias to its filename, leaving filenames as-is. - - A config `font` value may be either a family name from the FontManager - catalog ("press_start", "four_by_six", "five_by_seven") or a literal - filename; the former resolve here, the latter pass through unchanged. - """ - try: - catalogued = _font_catalog().common_fonts.get(font_name) - except Exception: - return font_name - return os.path.basename(catalogued) if catalogued else font_name - - -def _read_bdf_native_size(bdf_path: str) -> Optional[int]: - """A BDF file's one true pixel size, delegated to FontManager. - - Deliberately NOT reimplemented here: the plugin copies grew a variant - that scans the whole file and returns a partially collected value when - parsing raises, where FontManager's stops at the first STARTCHAR and - returns None on error. - """ - try: - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(bdf_path) - except Exception: - return None - - -class SportsCore(ABC): - # Which ScoreboardSkin render method this class's display path maps to. - # SportsLive inherits the default; SportsUpcoming/SportsRecent override. - SKIN_MODE = "live" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - self.logger = logger - self.config = config - self.cache_manager = cache_manager - self.config_manager = self.cache_manager.config_manager - if OddsManager: - try: - self.odds_manager = OddsManager( - self.cache_manager, self.config_manager) - except Exception as e: - self.logger.warning(f"Failed to initialize OddsManager: {e}") - self.odds_manager = None - else: - self.odds_manager = None - self.logger.warning("OddsManager not available - odds functionality disabled") - self.display_manager = display_manager - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - self.sport_key = sport_key - self.sport = None - self.league = None - - # Initialize new architecture components (will be overridden by sport-specific classes) - self.sport_config = None - self.api_extractor: APIDataExtractor - self.data_source: DataSource - self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key - self.is_enabled: bool = self.mode_config.get("enabled", False) - self.show_odds: bool = self.mode_config.get("show_odds", False) - # Use LogoDownloader to get the correct default logo directory for this sport - default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) - self.logo_dir = self._initialize_logo_dir(default_logo_dir) - self.update_interval: int = self.mode_config.get( - "update_interval_seconds", 60) - self.show_records: bool = self.mode_config.get('show_records', False) - self.show_ranking: bool = self.mode_config.get('show_ranking', False) - # Number of games to show (instead of time-based windows) - self.recent_games_to_show: int = self.mode_config.get( - "recent_games_to_show", 5) # Show last 5 games - self.upcoming_games_to_show: int = self.mode_config.get( - "upcoming_games_to_show", 10) # Show next 10 games - self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False) - self.show_all_live: bool = self.mode_config.get("show_all_live", False) - - self.session = requests.Session() - retry_strategy = Retry( - total=5, # increased number of retries - backoff_factor=1, # increased backoff factor - # added 429 to retry list - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["GET", "HEAD", "OPTIONS"] - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("https://", adapter) - self.session.mount("http://", adapter) - - # LRU-bounded: entries are decoded RGBA thumbnails, not file bytes. - # Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on - # a 256x64 panel, more for wide wordmarks. The key is a team - # abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so - # an unbounded dict here held the whole league: ~11-18MB per manager - # instance, and a league runs three (live/recent/upcoming) each with - # its own cache. That is real money on a 1GB Pi. - self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict() - - # Font caches for _load_custom_font_from_element_config: per-frame - # callers (font-ladder walks) resolve the same (name, size) over and - # over, and the BDF strike size means re-reading a file header. - # Both are released in cleanup(). - self._font_cache: Dict[Tuple[str, int], Any] = {} - self._bdf_native_size_cache: Dict[str, Optional[int]] = {} - - # Set up headers - self.headers = { - 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive' - } - self.last_update = 0 - self.current_game = None - # Cooldown clocks for _should_log(), one per warning type. Initialized - # here rather than lazily: _should_log() reads them unguarded, so - # whichever warning fires first would otherwise raise AttributeError - # instead of logging. `_last_warning_time` is the single-clock field - # the plugin copies expose; kept for subclasses that read it. - self._warning_cooldowns: Dict[str, float] = {} - self._last_warning_time = 0 - self.fonts = self._load_fonts() - - # Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a - # skin id applied to all modes, or a per-mode mapping like - # {"live": "retro", "recent": "built-in"}. Loaded lazily on first - # render so a broken skin can never block startup. - self._skin_config = self.mode_config.get("skin") - self.skin_options = self.mode_config.get("skin_options", {}) or {} - self._skin = None - self._skin_load_attempted = False - self._skin_failures = 0 - self._skin_slow_renders = 0 - - # Initialize dynamic team resolver and resolve favorite teams - self.dynamic_resolver = DynamicTeamResolver() - raw_favorite_teams = self.mode_config.get("favorite_teams", []) - self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key) - - # Log dynamic team resolution - if raw_favorite_teams != self.favorite_teams: - self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}") - else: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - - self.logger.setLevel(logging.INFO) - - # Initialize team rankings cache - self._team_rankings_cache = {} - self._rankings_cache_timestamp = 0 - self._rankings_cache_duration = 3600 # Cache rankings for 1 hour - - # Initialize background data service with optimized settings - # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries - self.background_service = get_background_service(self.cache_manager, max_workers=1) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info("Background service enabled with 1 worker (memory optimized)") - - def _initialize_logo_dir(self, configured_path: Path) -> Path: - """Resolve and ensure a writable logo directory, falling back when necessary.""" - downloader = LogoDownloader() - resolved_configured = self._resolve_project_path(configured_path) - candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured) - - for candidate in candidates: - candidate_path = self._resolve_project_path(candidate) - if downloader.ensure_logo_directory(str(candidate_path)): - if candidate_path != resolved_configured: - self.logger.warning( - "Configured logo directory '%s' is not writable; using fallback '%s'", - resolved_configured, - candidate_path, - ) - return candidate_path - - self.logger.error( - "Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)", - resolved_configured, - ) - return resolved_configured - - def _resolve_project_path(self, path: Path) -> Path: - """Convert relative paths to absolute ones rooted at the project directory.""" - if path.is_absolute(): - return path - return (_INSTALL_ROOT / path).resolve() - - def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: - """Return fallback directories to try when the configured directory is not writable.""" - fallbacks: List[Path] = [] - - env_override = os.environ.get("LEDMATRIX_LOGO_DIR") - if env_override: - env_path = Path(env_override) - if not env_path.is_absolute(): - env_path = self._resolve_project_path(env_path) - fallbacks.append(env_path / self.sport_key) - - cache_dir = getattr(self.cache_manager, "cache_dir", None) - if cache_dir: - fallbacks.append(Path(cache_dir) / "logos" / self.sport_key) - - try: - fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key) - except RuntimeError as e: - self.logger.debug("Could not resolve home directory (expected for service users): %s", e) - - fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key) - - unique_fallbacks: List[Path] = [] - seen = set() - for candidate in fallbacks: - if candidate == configured_dir: - continue - if candidate not in seen: - unique_fallbacks.append(candidate) - seen.add(candidate) - - return unique_fallbacks - - 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 - try: - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status']) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True) - - - def _resolve_skin_id(self) -> Optional[str]: - """The skin id configured for this instance's mode, or None for the - built-in renderer. Accepts a plain id (all modes) or a per-mode - mapping ({"live": "retro-baseball", "recent": "built-in"}).""" - skin_id = self._skin_config - if isinstance(skin_id, dict): - skin_id = skin_id.get(self.SKIN_MODE) - if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in": - return None - return skin_id - - def _get_skin(self): - """Lazily load the configured skin once. Returns None (built-in - renderer) when no skin is configured or loading failed.""" - if not self._skin_load_attempted: - self._skin_load_attempted = True - skin_id = self._resolve_skin_id() - if skin_id: - try: - from src.skin_system import skin_runtime - self._skin = skin_runtime.load_skin( - skin_id, sport=self.sport, sport_key=self.sport_key, - options=self.skin_options) - except Exception as e: - self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True) - self._skin = None - return self._skin - - def _render_game(self, game: Dict, force_clear: bool = False) -> None: - """Render one game: try the configured skin first, fall back to the - built-in _draw_scorebug_layout. A skin that raises 3 times in a row - is disabled for the rest of the session.""" - skin = self._get_skin() - if skin is not None and self._skin_failures < 3: - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game) - render = getattr(skin, f"render_{self.SKIN_MODE}") - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - if elapsed > 0.15 and self._skin_slow_renders < 5: - self._skin_slow_renders += 1 - self.logger.warning( - f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to " - f"render {self.SKIN_MODE} — slow renders stall the whole display loop") - if handled: - self._skin_failures = 0 - self.display_manager.image.paste(ctx.canvas, (0, 0)) - self.display_manager.update_display() - return - except Exception: - self._skin_failures += 1 - outcome = ("disabling skin for this session" if self._skin_failures >= 3 - else "falling back to built-in renderer") - self.logger.error( - f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} " - f"({self._skin_failures}/3); {outcome}", exc_info=True) - self._draw_scorebug_layout(game, force_clear) - - def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: - """Render one game as a standalone card via the configured skin — - for vegas mode and previews. Tries render_vegas_card at the given - size, then the mode renderer on a card-sized canvas. Returns None - when no skin is active or the skin declined, so callers can use - their default rendering.""" - skin = self._get_skin() - if skin is None or self._skin_failures >= 3: - return None - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game, size=size) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - # A successful render clears accumulated strikes, mirroring - # _render_game — transient failures must not add up across - # the session and disable a working skin. - self._skin_failures = 0 - return card - ctx = skin_runtime.build_context(self, game, size=size) - render = getattr(skin, f"render_{self.SKIN_MODE}") - if render(ctx, dict(game)): - self._skin_failures = 0 - return ctx.canvas - except Exception: - # Card failures count toward the same 3-strike session disable - # as display failures — a skin broken for vegas shouldn't get - # to throw on every scroll tick forever. - self._skin_failures += 1 - self.logger.error( - f"Skin '{self._resolve_skin_id()}' card render failed " - f"({self._skin_failures}/3)", exc_info=True) - return None - - def display(self, force_clear: bool = False) -> bool: - """Common display method for all NCAA FB managers""" # Updated docstring - if not self.is_enabled: # Check if module is enabled - return False - - if not self.current_game: - # Clear display if force_clear is True, even when there's no content - # This prevents black screens when switching to modes with no content - if force_clear: - try: - self.display_manager.clear() - self.display_manager.update_display() - except Exception as e: - self.logger.debug(f"Error clearing display when no content: {e}") - - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - getattr(self, '_last_warning_time', 0) > 300: - self.logger.warning(f"No game data available to display in {self.__class__.__name__}") - setattr(self, '_last_warning_time', current_time) - return False - - try: - self._render_game(self.current_game, force_clear) - # display_manager.update_display() should be called within subclass draw methods - # or after calling display() in the main loop. Let's keep it out of the base display. - return True - except Exception as e: - self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True) - return False - - - def _load_fonts(self) -> Dict[str, Any]: - """Load fonts used by the scoreboard. - - Paths go through :meth:`_resolve_font_path` so the bundled fonts are - found regardless of the process working directory — a bare - ``"assets/fonts/..."`` silently degraded every scoreboard to the PIL - default font whenever the process started elsewhere (the plugin safety - harness on CI being the case that surfaced it). - """ - fonts: Dict[str, Any] = {} - press_start = self._resolve_font_path("PressStart2P-Regular.ttf") - four_by_six = self._resolve_font_path("4x6-font.ttf") - try: - fonts['score'] = load_truetype(press_start, 10) - fonts['time'] = load_truetype(press_start, 8) - fonts['team'] = load_truetype(press_start, 8) - fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status - fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font - fonts['rank'] = load_truetype(press_start, 10) - self.logger.info("Successfully loaded fonts") - except OSError: - # Name the directory we searched: the usual cause is an install - # whose assets/fonts is missing, and the bare message sent people - # hunting for a font-format problem instead. - self.logger.warning( - "Fonts not found under %s, using default PIL font.", - self._font_root(), - ) - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - fonts['detail'] = ImageFont.load_default() - fonts['rank'] = ImageFont.load_default() - return fonts - - def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: - """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" - home_team_odds = odds.get('home_team_odds', {}) - away_team_odds = odds.get('away_team_odds', {}) - home_spread = home_team_odds.get('spread_odds') - away_spread = away_team_odds.get('spread_odds') - - # Get top-level spread as fallback - top_level_spread = odds.get('spread') - - # If we have a top-level spread and the individual spreads are None or 0, use the top-level - if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: - home_spread = top_level_spread - if away_spread is None: - away_spread = -top_level_spread - - # Determine which team is favored (has negative spread) - home_favored = home_spread is not None and home_spread < 0 - away_favored = away_spread is not None and away_spread < 0 - - # Only show the negative spread (favored team) - favored_spread = None - favored_side = None - - if home_favored: - favored_spread = home_spread - favored_side = 'home' - self.logger.debug(f"Home team favored with spread: {favored_spread}") - elif away_favored: - favored_spread = away_spread - favored_side = 'away' - self.logger.debug(f"Away team favored with spread: {favored_spread}") - else: - self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") - - # Show the negative spread on the appropriate side - if favored_spread is not None: - spread_text = str(favored_spread) - font = self.fonts['detail'] # Use detail font for odds - - if favored_side == 'home': - # Home team is favored, show spread on right side - spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing home spread '{spread_text}' on right side") - else: - # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing away spread '{spread_text}' on left side") - - # Show over/under on the opposite side of the favored team - over_under = odds.get('over_under') - if over_under is not None: - ou_text = f"O/U: {over_under}" - font = self.fonts['detail'] # Use detail font for odds - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home team is favored, show O/U on left side (opposite of spread) - ou_x = 0 # Top left - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") - elif favored_side == 'away': - # Away team is favored, show O/U on right side (opposite of spread) - ou_x = width - ou_width # Top right - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") - else: - # No clear favorite, show O/U in center - ou_x = (width - ou_width) // 2 - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") - - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Draw text with a black outline for better readability.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - #: Decoded logos to keep. A scroll of "other games" shows on the order of - #: 20 games (40 teams), so this holds a full cycle without thrashing while - #: capping the cache well below a 307-team league. - _LOGO_CACHE_MAX = 64 - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: - """Load and resize a team logo, with caching and automatic download if missing.""" - self.logger.debug(f"Logo path: {logo_path}") - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - self._logo_cache.move_to_end(team_abbrev) - return self._logo_cache[team_abbrev] - - try: - # Try different filename variations first (for cases like TA&M vs TAANDM) - actual_logo_path = None - filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev) - - for filename in filename_variations: - test_path = logo_path.parent / filename - if test_path.exists(): - actual_logo_path = test_path - self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") - break - - # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): - self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") - - # Try to download the logo from ESPN API (this will create placeholder if download fails) - download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url) - actual_logo_path = logo_path - - # Use the original path if no alternative was found - if not actual_logo_path: - actual_logo_path = logo_path - - # Only try to open the logo if the file exists - if os.path.exists(actual_logo_path): - logo = Image.open(actual_logo_path) - else: - self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") - return None - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo - while len(self._logo_cache) > self._LOGO_CACHE_MAX: - self._logo_cache.popitem(last=False) - return logo - - except Exception as e: - self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) - return None - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a specific game using the new architecture.""" - try: - if not self.show_odds: - return - - if not self.odds_manager: - return - - # Determine update interval based on game state - is_live = game.get('is_live', False) - update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ - else self.mode_config.get("odds_update_interval", 3600) - - # Fetch odds using OddsManager - odds_data = self.odds_manager.get_odds( - sport=self.sport, - league=self.league, - event_id=game['id'], - update_interval_seconds=update_interval, - ) - - if odds_data: - game['odds'] = odds_data - self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") - else: - self.logger.debug(f"No odds data returned for game {game['id']}") - - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Whether a warning of this kind is outside its cooldown window. - - Cooldowns are tracked **per ``warning_type``**. They previously shared - one timestamp, so the parameter was accepted and ignored: an API-error - warning would silence an unrelated cache warning for the next minute, - and whichever fired first won. Nothing in core called this, so no - behavior regressed with the fix — but every caller has always been - entitled to assume its own warning type has its own clock. - """ - current_time = time.time() - if current_time - self._warning_cooldowns.get(warning_type, 0) > cooldown: - self._warning_cooldowns[warning_type] = current_time - # Kept in step for subclasses that read it directly. - self._last_warning_time = current_time - return True - return False - - def _fetch_team_rankings(self) -> Dict[str, int]: - """Fetch team rankings using the new architecture components.""" - current_time = time.time() - - # Check if we have cached rankings that are still valid - if (self._team_rankings_cache and - current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): - return self._team_rankings_cache - - try: - data = self.data_source.fetch_standings(self.sport, self.league) - - rankings = {} - rankings_data = data.get('rankings', []) - - if rankings_data: - # Use the first ranking (usually AP Top 25) - first_ranking = rankings_data[0] - teams = first_ranking.get('ranks', []) - - for team_data in teams: - team_info = team_data.get('team', {}) - team_abbr = team_info.get('abbreviation', '') - current_rank = team_data.get('current', 0) - - if team_abbr and current_rank > 0: - rankings[team_abbr] = current_rank - - # Cache the results - self._team_rankings_cache = rankings - self._rankings_cache_timestamp = current_time - - self.logger.debug(f"Fetched rankings for {len(rankings)} teams") - return rankings - - except Exception as e: - self.logger.error(f"Error fetching team rankings: {e}") - return {} - - def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - if not game_event: - return None, None, None, None, None - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - logging.warning(f"Could not parse game date: {game_date_str}") - - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - try: - home_abbr = home_team["team"]["abbreviation"] - except KeyError: - home_abbr = home_team["team"]["name"][:3] - try: - away_abbr = away_team["team"]["abbreviation"] - except KeyError: - away_abbr = away_team["team"]["name"][:3] - - # Check if this is a favorite team game BEFORE doing expensive logging - is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) - - # Only log debug info for favorite team games - if is_favorite_game: - self.logger.debug(f"Processing favorite team game: {game_event.get('id')}") - self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}") - - game_time, game_date = "", "" - if start_time_utc: - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%I:%M%p").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - - home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' - away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' - - # Don't show "0-0" records - set to blank instead - if home_record in {"0-0", "0-0-0"}: - home_record = '' - if away_record in {"0-0", "0-0-0"}: - away_record = '' - - details = { - "id": game_event.get("id"), - "game_time": game_time, - "game_date": game_date, - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34" - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": (status["type"]["state"] == "pre" or - status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']), - "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check - "is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check - "home_abbr": home_abbr, - "home_id": home_team["id"], - "home_score": home_team.get("score", "0"), - "home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), - "home_logo_url": home_team["team"].get("logo"), - "home_record": home_record, - "away_record": away_record, - "away_abbr": away_abbr, - "away_id": away_team["id"], - "away_score": away_team.get("score", "0"), - "away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), - "away_logo_url": away_team["team"].get("logo"), - "is_within_window": True, # Whether game is within display window - - } - return details, home_team, away_team, status, situation - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None, None, None, None, None - - @abstractmethod - def _extract_game_details(self, game_event: dict) -> dict | None: - details, _, _, _, _ = self._extract_game_details_common(game_event) - return details - - @abstractmethod - def _fetch_data(self) -> Optional[Dict]: - pass - - def _fetch_todays_games(self) -> Optional[Dict]: - """Fetch only today's games for live updates (not entire season).""" - try: - tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support - now = datetime.now(tz) - yesterday = now - timedelta(days=1) - formatted_date = now.strftime("%Y%m%d") - formatted_date_yesterday = yesterday.strftime("%Y%m%d") - # Fetch todays games only - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, - params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, - headers=self.headers, timeout=10, logger=self.logger, - ) - events = data.get('events', []) - - self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}") - return {'events': events} - except requests.exceptions.RequestException as e: - self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}") - return None - - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now + timedelta(weeks=-2) - end_date = now + timedelta(weeks=1) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, params={"dates": date_str, "limit": 1000}, - headers=self.headers, timeout=10, logger=self.logger, - ) - immediate_events = data.get('events', []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {'events': immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}") - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - # ------------------------------------------------------------------ - # Promoted from the plugin copies (see docs/SPORTS_UNIFICATION.md). - # Everything below is present in all nine bundled `sports.py` copies; - # the canonical form of each is documented on the method. - # ------------------------------------------------------------------ - - def _favorite_key(self, game: Dict, side: str) -> Optional[str]: - """Override point: which view-model field identifies a team when - matching against ``favorite_teams``. - - ``side`` is ``"home"`` or ``"away"``. The default is the team - abbreviation — what eight of the nine scoreboards match on, and what - users type into their favorites list. - - NRL overrides this to the team **id**, because NRL abbreviations are - not unique: "NEW" is both Newcastle Knights and New Zealand Warriors, - "CAN" both Canberra Raiders and Canterbury Bulldogs. Matching those by - abbreviation selects the wrong club. It is a seam rather than a branch - precisely so core never has to learn the string "nrl":: - - def _favorite_key(self, game, side): - return str(game.get(f"{side}_id")) - - An override that stringifies should note that a missing id becomes the - literal ``"None"``, which would spuriously match a favorites list - containing that string. The default returns ``None``, which never - matches. - """ - return game.get(f"{side}_abbr") - - def _config_schema_path(self) -> Optional[str]: - """Override point: the plugin's ``config_schema.json``, used as the - reference for style-resolver defaults. - - None (the default) means no schema is available — layout offsets are - then read with the classic inline config lookup, which is exactly what - a plugin that never shipped resolver support does today. A plugin - opting into :mod:`src.element_style` returns its own schema path:: - - def _config_schema_path(self): - return os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'config_schema.json') - - It must not be derived from this module's ``__file__``: after - promotion that resolves inside ``src/base_classes/sports/``, where no - ``config_schema.json`` exists. - """ - return None - - def _font_root(self) -> str: - """Override point: the directory ``assets/fonts`` resolves against. - - Defaults to the core install root, derived from this module's own - location — the same strategy as ``FontManager._resolve_asset_path``. - A plugin bundling its own fonts overrides this to return its plugin - directory. Never a cwd-relative path: fonts must load no matter where - the process was started from (e.g. the plugin safety harness on CI). - """ - return str(_INSTALL_ROOT) - - def _resolve_font_path(self, font_name: str) -> str: - """Locate a font file by filename, independently of the process cwd. - - Tries ``assets/fonts/`` relative to the cwd first (preserving - behavior for a process started from an install root), then under - :meth:`_font_root`. Returns the cwd-relative path unchanged when the - file is nowhere to be found, so callers log the familiar path. - """ - relative = os.path.join('assets', 'fonts', font_name) - if os.path.exists(relative): - return relative - candidate = os.path.join(self._font_root(), relative) - if os.path.exists(candidate): - return candidate - return relative - - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: - """ - Get layout offset for a specific element and axis. - - Args: - element: Element name (e.g., 'home_logo', 'score', 'status_text') - axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) - default: Default value if not configured (default: 0) - - Returns: - Offset value from config or default (always returns int) - """ - schema_path = self._config_schema_path() if STYLE_AVAILABLE else None - if schema_path: - # Shared resolver (rebuilt if the config dict was swapped out, - # matching the classic path's read-config-on-every-call semantics). - # Note it is stricter than the classic read below: a boolean offset - # degrades to the default instead of counting as 1/0, which is the - # more correct reading of a pixel offset. - resolver = getattr(self, '_style_resolver_cached', None) - if resolver is None or resolver._config is not self.config: - resolver = ElementStyleResolver( - self.config, defaults_from_schema_file(schema_path)) - self._style_resolver_cached = resolver - return resolver.offset_value(element, axis, default) - try: - layout_config = self.config.get('customization', {}).get('layout', {}) - element_config = layout_config.get(element, {}) - offset_value = element_config.get(axis, default) - - # Ensure we return an integer (handle float/string from config) - if isinstance(offset_value, (int, float)): - return int(offset_value) - elif isinstance(offset_value, str): - # Try to convert string to int - try: - return int(float(offset_value)) - except (ValueError, TypeError): - self.logger.warning( - f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}" - ) - return default - else: - return default - except Exception as e: - # Gracefully handle any config access errors - self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") - return default - - def _load_custom_font_from_element_config( - self, - element_config: Dict[str, Any], - default_size: int = 8, - default_font: Optional[str] = None, - ) -> ImageFont.FreeTypeFont: - """ - Load a custom font from an element configuration dictionary. - - Args: - element_config: Configuration dict for a single element containing 'font' and 'font_size' keys - default_size: Default font size if not specified in config - default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds) - - Returns: - PIL ImageFont object - """ - base_default = default_font or "PressStart2P-Regular.ttf" - font_name = element_config.get('font', base_default) - font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL - - # Resolve family aliases (e.g. "press_start") to real filenames, then - # locate the file against _font_root() rather than the cwd. - resolved_name = _resolve_font_family_alias(font_name) - font_path = self._resolve_font_path(resolved_name) - - # Memoized: per-frame callers (font-ladder walks) resolve the same - # (name, size) repeatedly -- return the previously loaded face. - cache_key = (resolved_name, font_size) - cached_font = self._font_cache.get(cache_key) - if cached_font is not None: - return cached_font - - # Try to load the font - try: - if os.path.exists(font_path): - # Try loading as TTF first (works for both TTF and some BDF files with PIL) - if font_path.lower().endswith('.ttf'): - font = load_truetype(font_path, font_size) - self.logger.debug(f"Loaded font: {font_name} at size {font_size}") - self._font_cache[cache_key] = font - return font - elif font_path.lower().endswith('.bdf'): - # BDF fonts are fixed-size bitmaps, not scalable outlines -- - # FreeType only accepts the exact pixel size baked into the - # file (its "strike") and raises "invalid pixel size" for - # anything else. Try the requested size first (in case it - # happens to match), then fall back to the file's real - # native size, so a BDF font can still be selected via - # font_size-driven configs without the caller needing to - # know its exact strike size. - # - # This retry is the OLDER lineage's behavior and it is the - # correct one: the newer copies call truetype() on a BDF at - # any size (which simply fails) or refuse BDF outright. - try: - font = load_truetype(font_path, font_size) - self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") - self._font_cache[cache_key] = font - return font - except OSError: - if font_path in self._bdf_native_size_cache: - native_size = self._bdf_native_size_cache[font_path] - else: - native_size = _read_bdf_native_size(font_path) - self._bdf_native_size_cache[font_path] = native_size - if native_size and native_size != font_size: - try: - font = load_truetype(font_path, native_size) - self.logger.debug( - f"Loaded BDF font: {font_name} at its native size {native_size} " - f"(requested {font_size} isn't a valid strike for this file)" - ) - self._font_cache[cache_key] = font - return font - except Exception as retry_exc: - self.logger.debug( - f"BDF font {font_name} also failed to load at native " - f"size {native_size}: {retry_exc}" - ) - self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") - # Fall through to default - else: - self.logger.warning(f"Unknown font file type: {font_name}, using default") - else: - self.logger.warning(f"Font file not found: {font_path}, using default") - except Exception as e: - self.logger.error(f"Error loading font {font_name}: {e}, using default") - - # Fall back to default font. Cached under the requested (name, size) - # key too, so a misconfigured or missing font pays the disk cost once - # instead of on every frame of a font-ladder walk. - default_font_path = self._resolve_font_path( - _resolve_font_family_alias(base_default)) - try: - if os.path.exists(default_font_path): - font = load_truetype(default_font_path, font_size) - else: - self.logger.warning("Default font not found, using PIL default") - font = ImageFont.load_default() - except Exception as e: - self.logger.error(f"Error loading default font: {e}") - font = ImageFont.load_default() - self._font_cache[cache_key] = font - return font - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - # Font caches hold PIL faces; without this they are an unbounded - # per-instance leak across enable/disable cycles. - if hasattr(self, '_font_cache'): - self._font_cache.clear() - if hasattr(self, '_bdf_native_size_cache'): - self._bdf_native_size_cache.clear() - - # NOTE: self.background_service is deliberately NOT shut down here. - # get_background_service() returns a PROCESS-WIDE singleton shared by - # every scoreboard; shutting it down from one unloading plugin would - # stop background fetching for all the others. Whoever owns the - # process owns its lifecycle. Do not "fix" this. - - self.logger.info(f"{self.__class__.__name__} cleanup completed") diff --git a/src/base_classes/sports/modes.py b/src/base_classes/sports/modes.py deleted file mode 100644 index 8f6e3cce..00000000 --- a/src/base_classes/sports/modes.py +++ /dev/null @@ -1,1097 +0,0 @@ -"""The three display modes layered on SportsCore: SportsUpcoming, -SportsRecent and SportsLive. Split out of the former -``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md. -""" - -import logging -import time -from abc import abstractmethod -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List - -from PIL import Image, ImageDraw, ImageFont - -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - -from .core import SportsCore - - -class SportsUpcoming(SportsCore): - SKIN_MODE = "upcoming" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.upcoming_games = [] # Store all fetched upcoming games initially - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get("upcoming_update_interval", 3600) # Check for recent games every hour - self.last_log_time = 0 - self.log_interval = 300 - self.last_warning_time = 0 - self.warning_cooldown = 300 - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( - self, processed_games: List[Dict], favorite_teams: List[str] - ) -> List[Dict]: - """ - Single-pass game selection with proper deduplication and counting. - - When a game involves two favorite teams, it counts toward BOTH teams' limits. - This prevents unexpected game counts from the multi-pass algorithm. - - Team identity goes through the ``_favorite_key`` override point rather - than reading ``home_abbr``/``away_abbr`` directly, because abbreviations - are not unique in every league (NRL matches on team ID instead). - """ - sorted_games = sorted( - processed_games, - key=lambda g: g.get("start_time_utc") - or datetime.max.replace(tzinfo=timezone.utc), - ) - - if not favorite_teams: - return sorted_games - - selected_games = [] - selected_ids = set() - team_counts = {team: 0 for team in favorite_teams} - - for game in sorted_games: - game_id = game.get("id") - if game_id in selected_ids: - continue - - home = self._favorite_key(game, "home") - away = self._favorite_key(game, "away") - - home_fav = home in favorite_teams - away_fav = away in favorite_teams - - if not home_fav and not away_fav: - continue - - home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show - away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show - - if home_needs or away_needs: - selected_games.append(game) - selected_ids.add(game_id) - if home_fav: - team_counts[home] += 1 - if away_fav: - team_counts[away] += 1 - - self.logger.debug( - f"Selected game {away}@{home}: team_counts={team_counts}" - ) - - if all(c >= self.upcoming_games_to_show for c in team_counts.values()): - self.logger.debug("All favorite teams satisfied, stopping selection") - break - - self.logger.info( - f"Selected {len(selected_games)} games for {len(favorite_teams)} " - f"favorite teams: {team_counts}" - ) - return selected_games - - def update(self): - """Update upcoming games data.""" - if not self.is_enabled: return - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - try: - data = self._fetch_data() # Uses shared cache - if not data or 'events' not in data: - self.logger.warning("No events found in shared data.") # Changed log prefix - if not self.games_list: self.current_game = None - return - - events = data['events'] - # self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix - - processed_games = [] - favorite_games_found = 0 - all_upcoming_games = 0 # Count all upcoming games regardless of favorites - - for event in events: - game = self._extract_game_details(event) - # Count all upcoming games for debugging - if game and game['is_upcoming']: - all_upcoming_games += 1 - - # Filter criteria: must be upcoming ('pre' state) - if game and game['is_upcoming']: - # Only fetch odds for games that will be displayed - if self.show_favorite_teams_only: - if not self.favorite_teams: - continue - if game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams: - continue - processed_games.append(game) - # Count favorite team games for logging - if (game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams): - favorite_games_found += 1 - # Odds are NOT fetched here -- see after selection below. - - # Enhanced logging for debugging - self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") - self.logger.info(f"Found {len(processed_games)} upcoming games after filtering") - - if processed_games: - for game in processed_games[:3]: # Show first 3 - self.logger.info(f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}") - - if self.favorite_teams and all_upcoming_games > 0: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - self.logger.info(f"Found {favorite_games_found} favorite team upcoming games") - - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Select N games per favorite team (where N = upcoming_games_to_show) - # Example: upcoming_games_to_show=2 with 3 favorite teams = up to 6 games total - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]: - # Sort by game time and take the earliest N games - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Take up to upcoming_games_to_show games for this team - team_games.extend(team_specific_games[:self.upcoming_games_to_show]) - - # Sort the final list by game time (earliest first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Remove duplicates (in case a game involves multiple favorite teams) - seen_ids = set() - unique_team_games = [] - for game in team_games: - if game['id'] not in seen_ids: - seen_ids.add(game['id']) - unique_team_games.append(game) - team_games = unique_team_games - else: - team_games = processed_games # Show all upcoming if no favorites - # Sort by game time, earliest first - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Limit to the specified number of upcoming games - team_games = team_games[:self.upcoming_games_to_show] - - # Odds are fetched here, for the games that survived selection, - # rather than in the loop that collects them. That loop walks every - # upcoming game in the schedule window, and for a college league - # the window is enormous -- a live rig logged 946 upcoming games in - # one cycle and displayed 1 of them. The comment up there claimed - # odds were fetched "only for games that will be displayed", but - # the only narrowing it applied was show_favorite_teams_only, which - # is not the default; in the usual case nothing narrowed it at all - # and every game cost a separate ESPN request on a Pi that is also - # driving the panel. - if self.show_odds: - for game in team_games: - self._fetch_odds(game) - - # Log changes or periodically - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(team_games) != len(self.games_list) or - any(g1['id'] != g2.get('id') for g1, g2 in zip(self.games_list, team_games)) or - (not self.games_list and team_games) - ) - - # Check if the list of games to display has changed - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in self.games_list} - - if new_game_ids != current_game_ids: - self.logger.info(f"Found {len(team_games)} upcoming games within window for display.") # Changed log prefix - self.games_list = team_games - if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time - else: - try: - self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id']) - self.current_game = self.games_list[self.current_game_index] - except StopIteration: - self.current_game_index = 0 - self.current_game = self.games_list[0] - self.last_game_switch = current_time - - elif self.games_list: - self.current_game = self.games_list[self.current_game_index] # Update data - - if not self.games_list: - self.logger.info("No relevant upcoming games found to display.") # Changed log prefix - self.current_game = None - - if should_log and not self.games_list: - # Log favorite teams only if no games are found and logging is needed - self.logger.debug(f"Favorite teams: {self.favorite_teams}") # Changed log prefix - self.logger.debug(f"Total upcoming games before filtering: {len(processed_games)}") # Changed log prefix - self.last_log_time = current_time - elif should_log: - self.last_log_time = current_time - - except Exception as e: - self.logger.error(f"Error updating upcoming games: {e}", exc_info=True) # Changed log prefix - # self.current_game = None # Decide if clear on error - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the layout for an upcoming NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positions - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # Note: Rankings are now handled in the records/rankings section below - - # "Next Game" at the top (use smaller status font) - status_font = self.fonts['status'] - if self.display_width > 128: - status_font = self.fonts['time'] - status_text = "Next Game" - status_width = draw_overlay.textlength(status_text, font=status_font) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Changed from 2 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), status_font) - - # Date text (centered, below "Next Game") - date_width = draw_overlay.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - # Adjust Y position to stack date and time nicely - date_y = center_y - 7 # Raise date slightly - self._draw_text_with_outline(draw_overlay, game_date, (date_x, date_y), self.fonts['time']) - - # Time text (centered, below Date) - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 9 # Place time below date - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 0 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here - - except Exception as e: - self.logger.error(f"Error displaying upcoming game: {e}", exc_info=True) # Changed log prefix - - def display(self, force_clear=False) -> bool: - """Display upcoming games, handling switching.""" - if not self.is_enabled: return False - - if not self.games_list: - if self.current_game: self.current_game = None # Clear state if list empty - current_time = time.time() - # Log warning periodically if no games found - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.info("No upcoming games found for favorite teams to display.") # Changed log prefix - self.last_warning_time = current_time - return False # Skip display update - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force redraw on switch - - # Log team switching with sport prefix - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT' - self.logger.info(f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}") - else: - self.logger.debug(f"Switched to game index {self.current_game_index}") - - if self.current_game: - self._render_game(self.current_game, force_clear) - return True - # update_display() is called within _draw_scorebug_layout for upcoming - return False - - except Exception as e: - self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix - return False - - -class SportsRecent(SportsCore): - SKIN_MODE = "recent" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.recent_games = [] # Store all fetched recent games initially - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each recent game for 15 seconds - # Tracks when each game was first seen with an expired clock, keyed by - # game id. Promoted alongside the zero-clock helpers below; without it - # the first _get_zero_clock_duration() call raises AttributeError. - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - # -- Zero-clock tracking ------------------------------------------------ - # Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define - # these but never call them — their clocks count up, so 0:00 means kickoff - # rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the - # pair a future `CountdownClockMixin` candidate so it stops appearing in the - # MRO of sports that cannot use it — B2 work, not now. - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] - - def _select_recent_games_for_display( - self, processed_games: List[Dict], favorite_teams: List[str] - ) -> List[Dict]: - """ - Single-pass game selection for recent games with proper deduplication. - - When a game involves two favorite teams, it counts toward BOTH teams' limits. - Games are sorted by most recent first. - - Team identity goes through the ``_favorite_key`` override point rather - than reading ``home_abbr``/``away_abbr`` directly, because abbreviations - are not unique in every league (NRL matches on team ID instead). - """ - sorted_games = sorted( - processed_games, - key=lambda g: g.get("start_time_utc") - or datetime.min.replace(tzinfo=timezone.utc), - reverse=True, - ) - - if not favorite_teams: - return sorted_games - - selected_games = [] - selected_ids = set() - team_counts = {team: 0 for team in favorite_teams} - - for game in sorted_games: - game_id = game.get("id") - if game_id in selected_ids: - continue - - home = self._favorite_key(game, "home") - away = self._favorite_key(game, "away") - - home_fav = home in favorite_teams - away_fav = away in favorite_teams - - if not home_fav and not away_fav: - continue - - home_needs = home_fav and team_counts[home] < self.recent_games_to_show - away_needs = away_fav and team_counts[away] < self.recent_games_to_show - - if home_needs or away_needs: - selected_games.append(game) - selected_ids.add(game_id) - if home_fav: - team_counts[home] += 1 - if away_fav: - team_counts[away] += 1 - - self.logger.debug( - f"Selected recent game {away}@{home}: team_counts={team_counts}" - ) - - if all(c >= self.recent_games_to_show for c in team_counts.values()): - self.logger.debug("All favorite teams satisfied, stopping selection") - break - - self.logger.info( - f"Selected {len(selected_games)} recent games for {len(favorite_teams)} " - f"favorite teams: {team_counts}" - ) - return selected_games - - def update(self): - """Update recent games data.""" - if not self.is_enabled: return - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time # Update time even if fetch fails - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - try: - data = self._fetch_data() # Uses shared cache - if not data or 'events' not in data: - self.logger.warning("No events found in shared data.") # Changed log prefix - if not self.games_list: - self.current_game = None # Clear display if no games were showing - return - - events = data['events'] - self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix - - # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) - now = datetime.now(timezone.utc) - recent_cutoff = now - timedelta(days=21) - self.logger.info(f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)") - - # Process games and filter for final games, date range & favorite teams - processed_games = [] - for event in events: - game = self._extract_game_details(event) - # Filter criteria: must be final AND within recent date range - if game and game['is_final']: - game_time = game.get('start_time_utc') - if game_time and game_time >= recent_cutoff: - processed_games.append(game) - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Get all games involving favorite teams - favorite_team_games = [game for game in processed_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - self.logger.info(f"Found {len(favorite_team_games)} favorite team games out of {len(processed_games)} total final games within last 21 days") - - # Select N games per favorite team (where N = recent_games_to_show) - # Example: recent_games_to_show=1 with 2 favorite teams = 2 games total - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the most recent N games - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - # Take up to recent_games_to_show games for this team - team_games.extend(team_specific_games[:self.recent_games_to_show]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - # Remove duplicates (in case a game involves multiple favorite teams) - seen_ids = set() - unique_team_games = [] - for game in team_games: - if game['id'] not in seen_ids: - seen_ids.add(game['id']) - unique_team_games.append(game) - team_games = unique_team_games - - # Debug: Show which games are selected for display - for i, game in enumerate(team_games): - self.logger.info(f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}") - else: - team_games = processed_games # Show all recent games if no favorites defined - self.logger.info(f"Found {len(processed_games)} total final games within last 21 days (no favorite teams filtering)") - # Sort games by start time, most recent first, and limit to recent_games_to_show - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games = team_games[:self.recent_games_to_show] - - # Check if the list of games to display has changed - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in self.games_list} - - if new_game_ids != current_game_ids: - self.logger.info(f"Found {len(team_games)} final games within window for display.") # Changed log prefix - self.games_list = team_games - # Reset index if list changed or current game removed - if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time # Reset switch timer - else: - # Try to maintain position if possible - try: - self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id']) - self.current_game = self.games_list[self.current_game_index] # Update data just in case - except StopIteration: - self.current_game_index = 0 - self.current_game = self.games_list[0] - self.last_game_switch = current_time - - elif self.games_list: - # List content is same, just update data for current game - self.current_game = self.games_list[self.current_game_index] - - if not self.games_list: - self.logger.info("No relevant recent games found to display.") # Changed log prefix - self.current_game = None # Ensure display clears if no games - - except Exception as e: - self.logger.error(f"Error updating recent games: {e}", exc_info=True) # Changed log prefix - # Don't clear current game on error, keep showing last known state - # self.current_game = None # Decide if we want to clear display on error - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the layout for a recently completed NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix - # Draw placeholder text if logos fail (similar to live) - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positioning (closer to edges) - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - # Note: Rankings are now handled in the records/rankings section below - - # Final Scores (Centered, same position as live) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 14 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # "Final" text (Top center) - status_text = game.get("period_text", "Final") # Use formatted period text (e.g., "Final/OT") or default "Final" - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 0 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - self._custom_scorebug_layout(game, draw_overlay) - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here - - except Exception as e: - self.logger.error(f"Error displaying recent game: {e}", exc_info=True) # Changed log prefix - - def display(self, force_clear=False) -> bool: - """Display recent games, handling switching.""" - if not self.is_enabled or not self.games_list: - # If disabled or no games, ensure display might be cleared by main loop if needed - # Or potentially clear it here? For now, rely on main loop/other managers. - if not self.games_list and self.current_game: - self.current_game = None # Clear internal state if list becomes empty - return False - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force redraw on switch - - # Log team switching with sport prefix - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT' - self.logger.info(f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}") - else: - self.logger.debug(f"Switched to game index {self.current_game_index}") - - if self.current_game: - self._render_game(self.current_game, force_clear) - return True - # update_display() is called within _draw_scorebug_layout for recent - return False - - except Exception as e: - self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix - return False - -class SportsLive(SportsCore): - # Per-sport constants for the "is this live game actually over?" check. - # These are values, not behavior, so they are class attributes rather than - # override points (see docs/SPORTS_UNIFICATION.md "Override points"). - # - # FINAL_PERIOD: the period at/after which an expired clock can mean "over". - # 4 for four-quarter sports; hockey overrides to 3. - # CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for - # sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff — - # running the expiry branch there would evict games that just started. - FINAL_PERIOD = 4 - CLOCK_COUNTS_DOWN = True - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 - self.last_update = 0 - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 # Will be set to current_time when games are first loaded - self.game_display_duration = self.mode_config.get("live_game_duration", 20) - self.last_display_update = 0 - self.last_log_time = 0 - self.log_interval = 300 - self.last_count_log_time = 0 # Track when we last logged count data - self.count_log_interval = 5 # Only log count data every 5 seconds - # Initialize test_mode - defaults to False (live mode) - self.test_mode = self.mode_config.get("test_mode", False) - # Freshness bookkeeping for _detect_stale_games(). The base class only - # *reads* this map; a subclass's update() stamps entries as it ingests a - # feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}. - # Until a subclass writes "last_seen", the staleness branch of - # _detect_stale_games is inert and only the game-over check applies. - self.game_update_timestamps: Dict[str, Dict[str, float]] = {} - self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default - - @abstractmethod - def _test_mode_update(self) -> None: - return - - def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. - - Two independent signals: - 1. ``period_text`` says "final" — universal across every sport. - 2. The clock has expired at/after :attr:`FINAL_PERIOD` — only meaningful - where :attr:`CLOCK_COUNTS_DOWN` is true. - - Fails *safe*: anything ambiguous returns False and the game keeps being - displayed. The only caller, :meth:`_detect_stale_games`, removes games - on a True, so a false positive silently drops a live game. - """ - game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - - # `period_text` may be present-but-None; `or ""` keeps that from raising - # AttributeError — the caller has no try/except around this call. - period_text = (game.get("period_text") or "").lower() - if "final" in period_text: - self.logger.debug( - f"_is_game_really_over({game_str}): " - f"returning True - 'final' in period_text='{period_text}'" - ) - return True - - if not self.CLOCK_COUNTS_DOWN: - # Count-up clock: 0:00 means the match has not started. - self.logger.debug( - f"_is_game_really_over({game_str}): returning False " - f"(count-up clock, period_text='{period_text}')" - ) - return False - - raw_clock = game.get("clock") - # `or 0` rather than a get() default: feeds routinely send an explicit - # null period, and `None >= FINAL_PERIOD` raises TypeError — which would - # take down the whole live-update pass, since the only caller - # (_detect_stale_games) has no try/except around it. - period = game.get("period") or 0 - - # Only check clock-based finish if we have a valid clock string. A - # missing or non-string clock is NOT coerced to "0:00": sports without a - # game clock (e.g. baseball, where `period` is the inning) would - # otherwise be declared over from the FINAL_PERIOD-th period onward. - if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD: - clock = raw_clock - # Compare numerically rather than against a literal set: feeds spell - # an expired clock "0:00", ":00" and "00:00" depending on sport, and - # a membership test silently misses every spelling not listed. - clock_normalized = clock.replace(":", "").strip() - if clock_normalized.isdigit() and int(clock_normalized) == 0: - self.logger.debug( - f"_is_game_really_over({game_str}): " - f"returning True - clock at 0:00 (clock='{clock}', period={period})" - ) - return True - - self.logger.debug( - f"_is_game_really_over({game_str}): returning False" - ) - return False - - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated. - - Mutates ``games`` **in place** and returns None. Removal is by value - (``list.remove`` uses ``dict.__eq__``), so two structurally-equal game - dicts in the same list would drop the first occurrence. - """ - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def update(self): - """Update live game data and handle game switching.""" - if not self.is_enabled: - return - - # Define current_time and interval before the problematic line (originally line 455) - # Ensure 'import time' is present at the top of the file. - current_time = time.time() - - # Define interval using a pattern similar to NFLLiveManager's update method. - # Uses getattr for robustness, assuming attributes for live_games, test_mode, - # no_data_interval, and update_interval are available on self. - _live_games_attr = self.live_games - _test_mode_attr = self.test_mode # test_mode is often from a base class or config - _no_data_interval_attr = self.no_data_interval # Default similar to NFLLiveManager - _update_interval_attr = self.update_interval # Default similar to NFLLiveManager - - interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr - - # Original line from traceback (line 455), now with variables defined: - if current_time - self.last_update >= interval: - self.last_update = current_time - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - if self.test_mode: - # Simulate clock running down in test mode - self._test_mode_update() - else: - # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if data and "events" in data: - for game in data["events"]: - details = self._extract_game_details(game) - if details and (details["is_live"] or details["is_halftime"]): - # If show_favorite_teams_only is true, only add if it's a favorite. - # Otherwise, add all games. - if self.show_all_live or not self.show_favorite_teams_only or (self.show_favorite_teams_only and (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams)): - if self.show_odds: - self._fetch_odds(details) - new_live_games.append(details) - # Log changes or periodically - current_time_for_log = time.time() # Use a consistent time for logging comparison - should_log = ( - current_time_for_log - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed - (not self.live_games and new_live_games) # Log if games appeared - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "all teams" - self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.") - for game_info in new_live_games: # Renamed game to game_info - self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})") - else: - filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "criteria" - self.logger.info(f"No live/halftime games found for {filter_text}.") - self.last_log_time = current_time_for_log - - - # Update game list and current game - if new_live_games: - # Check if the games themselves changed, not just scores/time - new_game_ids = {g['id'] for g in new_live_games} - current_game_ids = {g['id'] for g in self.live_games} - - if new_game_ids != current_game_ids: - self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time - # Reset index if current game is gone or list is new - if not self.current_game or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None - self.last_game_switch = current_time - else: - # Find current game's new index if it still exists - try: - self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id']) - self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data - # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching - if self.last_game_switch == 0: - self.last_game_switch = current_time - except StopIteration: # Should not happen if check above passed, but safety first - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - - else: - # Just update the data for the existing games - temp_game_dict = {g['id']: g for g in new_live_games} - self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place - if self.current_game: - self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game) - # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching - # This handles the case where games were loaded previously but last_game_switch was never set - if self.last_game_switch == 0: - self.last_game_switch = current_time - - # Display update handled by main loop based on interval - - else: - # No live games found - if self.live_games: # Were there games before? - self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix - self.live_games = [] - self.current_game = None - self.current_game_index = 0 - - else: - # Error fetching data or no events - if self.live_games: # Were there games before? - self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix - else: - self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix - self.current_game = None # Clear current game if fetch fails and no games were active - - # Handle game switching (outside test mode check) - # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet) - # This prevents immediate switching when the system has been running for a while before games load - if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.live_games) - self.current_game = self.live_games[self.current_game_index] - self.last_game_switch = current_time - self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix - # Force display update via flag or direct call if needed, but usually let main loop handle diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index 4b455069..efb10bba 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -50,8 +50,7 @@ class BaseOddsManager: # 2026-08-04 it began 403ing browser strings and bare custom tokens # alike; what it accepts is a token with a URL that says who is # calling. Every other ESPN caller in the tree already sends this - # (src/common/api_helper.py, src/base_classes/data_sources.py); the - # odds path was simply missed, and it is the one whose failures cost + # (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost # the caller its whole update budget. # # Deliberately no retry adapter, unlike api_helper: retries multiply diff --git a/src/common/sports_helpers.py b/src/common/sports_helpers.py index 6ae1c517..b78f0ca5 100644 --- a/src/common/sports_helpers.py +++ b/src/common/sports_helpers.py @@ -2,9 +2,8 @@ Ten helpers are byte-identical (executable AST, docstrings stripped) in the scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in -``src/common``. The bodies below were copied from those plugin copies -- not -from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main, -2026-09-14): +``src/common``. The bodies below were copied from those plugin copies at +ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14): - In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl, soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh`` @@ -34,9 +33,9 @@ where both can. Plugins import this and floor ``ledmatrix_min_version`` on the first core release that ships it (see ``CHANGELOG.md``). ``_favorite_key`` is the one method not taken from the plugins: it is the -override point from ``src/base_classes/sports/core.py``, carried here so later -phases (shared celebrations and game selection) have a hardware-free home for -the seam. No plugin defines it today and nothing in this module calls it. +override point from the since-removed ``src/base_classes`` sports core, +carried here so later phases (shared celebrations and game selection) have a +hardware-free home for the seam. No plugin defines it today and nothing in this module calls it. WHAT A HOST MUST PROVIDE ------------------------ @@ -80,8 +79,8 @@ runs. By convention list it after ``SportsCoreSharedMixin``:: HARDWARE-FREE ------------- -Nothing here may import ``src.display_manager``, ``src.base_classes``, -``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module +Nothing here may import ``src.display_manager``, ``src.plugin_system`` or +anything else that reaches ``rgbmatrix`` at module level; ``test/test_common_is_hardware_free.py`` enforces that for all of ``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily, exactly as the plugin copy does. @@ -224,8 +223,8 @@ class SportsHelpersMixin: containing that string. The default returns ``None`` for a missing abbreviation, which never matches. - Carried from ``src/base_classes/sports/core.py`` for later phases; - nothing in this module calls it yet. + Carried from the since-removed ``src/base_classes`` sports core for + later phases; nothing in this module calls it yet. """ return game.get(f"{side}_abbr") diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index d4eb70b8..0042f75e 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -99,8 +99,7 @@ def normalize_legacy_booleans(config: Any, schema: Any, #: config section, so they are allowed in every plugin's config whether or not #: the plugin's schema declares them. The one list for validation, for the web #: save filter and for the load-time checks -- a private copy is how JSON saves -#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted -#: them. +#: came to drop the ``vegas_*`` keys while the validator accepted them. #: #: Values are the schema used when the plugin does not declare the property. CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { @@ -123,19 +122,6 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { "default": False, "description": "Enable live priority takeover when plugin has live content" }, - # Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here: - # validation must keep passing when a configured skin gets uninstalled - # (rendering falls back to built-in). The install-dependent enum is - # injected only at serve time (inject_skin_selector) for the web UI - # dropdown. - "skin": { - "type": ["string", "object", "null"], - "description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}" - }, - "skin_options": { - "type": "object", - "description": "Options passed through to the selected skin" - }, # Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py. # Left untyped: the adapter validates them itself and ignores a bad # value with a log line, so a stored one must never block a save. @@ -158,6 +144,32 @@ CORE_VEGAS_TUNING_KEYS = frozenset({ }) +#: Per-plugin keys the core used to own and no longer reads. ``skin`` and +#: ``skin_options`` belonged to the skin system, which was removed; a +#: config.json written before then can still carry them in any plugin section, +#: and most plugin schemas set ``additionalProperties: false``. They are +#: dropped wherever a section is prepared (prepare_plugin_config) or validated, +#: and the web saves drop them from the stored section, so an old config loads +#: and saves without a validation error and loses them on its next save. +RETIRED_PLUGIN_KEYS = frozenset({'skin', 'skin_options'}) + + +def drop_retired_plugin_keys(config: Any, schema: Any) -> Any: + """``config`` without the RETIRED_PLUGIN_KEYS its plugin's schema leaves undeclared. + + A plugin whose schema declares one of these names owns it and keeps it; + without a schema nothing is dropped. Never mutates ``config``, and returns + it unchanged when there is nothing to drop. + """ + if not isinstance(config, dict) or not isinstance(schema, dict) \ + or RETIRED_PLUGIN_KEYS.isdisjoint(config): + return config + declared = schema.get('properties') + declared = declared if isinstance(declared, dict) else {} + return {key: value for key, value in config.items() + if key not in RETIRED_PLUGIN_KEYS or key in declared} + + def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]: """A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed. @@ -299,14 +311,15 @@ def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]], changed_paths: Optional[List[str]] = None) -> Dict[str, Any]: """The config a plugin runs with, from its stored (or submitted) section. - Legacy booleans are read as ``{"enabled": ...}`` objects - (normalize_legacy_booleans), then schema defaults fill in whatever is - missing. Loading a plugin, both config saves, GET /plugins/config, hot - reload and the dev tools all go through this, so a plugin sees the same - shape however its config reached it. + Retired core keys are dropped (drop_retired_plugin_keys), legacy booleans + are read as ``{"enabled": ...}`` objects (normalize_legacy_booleans), then + schema defaults fill in whatever is missing. Loading a plugin, both config + saves, GET /plugins/config, hot reload and the dev tools all go through + this, so a plugin sees the same shape however its config reached it. """ config = config if isinstance(config, dict) else {} if schema: + config = drop_retired_plugin_keys(config, schema) config = normalize_legacy_booleans(config, schema, changed_paths) return merge_config_defaults(config, defaults) @@ -620,7 +633,8 @@ class SchemaManager: # Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by # the base plugin system and should not cause validation failures: # they are allowed even when the plugin's schema doesn't declare - # them, and never required. + # them, and never required. Retired ones are ignored. + config = drop_retired_plugin_keys(config, schema) enhanced_schema = with_core_plugin_properties(schema) if plugin_id: declared = schema.get("properties", {}) if isinstance(schema, dict) else {} @@ -658,53 +672,6 @@ class SchemaManager: self.logger.error(error_msg) return False, [error_msg] - def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str, - current_value: Any = None) -> Dict[str, Any]: - """Return a copy of a plugin's schema with a "skin" dropdown added - when installed skins target this plugin (docs/SKIN_SYSTEM.md). - - Serve-time only — validation never sees this enum, so a config - referencing an uninstalled skin stays valid (rendering falls back - to the built-in layout). The currently-configured value is always - included in the enum for the same reason: the dropdown must be able - to display a selection whose skin was removed. - """ - # A per-mode mapping ({"live": ..., "recent": ...}) can't be edited - # through a string dropdown — injecting one would let the form save - # a string over the mapping. Leave the schema alone; per-mode users - # edit via the raw JSON config editor. - if isinstance(current_value, dict): - return schema - - try: - from src.skin_system import skin_runtime - matching = skin_runtime.skins_for_plugin(plugin_id) - except Exception as e: - self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}") - return schema - - choices = sorted(matching.keys()) - if isinstance(current_value, str) and current_value and \ - current_value != "built-in" and current_value not in choices: - choices.append(current_value) - if not choices: - return schema - - enhanced = copy.deepcopy(schema) - enhanced.setdefault("properties", {}) - if "skin" not in enhanced["properties"]: - names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices} - enhanced["properties"]["skin"] = { - "type": "string", - "title": "Visual Skin", - "description": "Replace this scoreboard's look with an installed skin " - "(data, scheduling, and vegas mode are unaffected)", - "enum": ["built-in", *choices], - "enumNames": ["Built-in", *(names[sid] for sid in choices)], - "default": "built-in" - } - return enhanced - def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str: """ Format a validation error into a readable message. diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 46174f99..12e74212 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -53,6 +53,17 @@ class PluginStoreManager: # "..", "../x") into a filesystem path that purge_uninstalled_plugins # would delete — an empty id resolves to the plugins root itself. _PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + @staticmethod + def is_plugin_entry(entry) -> bool: + """Whether a registry entry is a plugin core can install. + + A missing ``type`` means plugin. Anything else (registries used to + carry ``"type": "skin"`` entries, and a custom registry still can) is + hidden from the store and refused at install, rather than being + unpacked into the plugins directory as if it were a plugin. + """ + return isinstance(entry, dict) and (entry.get('type') or 'plugin') == 'plugin' def __init__(self, plugins_dir: str = "plugins", uninstalled_registry_path: Optional[str] = None): @@ -1314,18 +1325,10 @@ class PluginStoreManager: if not plugin_info: self.logger.error(f"Plugin not found in registry: {plugin_id}") return False - - # Visual skins share the registry. _install_skin_from_info can put one - # in skins/, but no current scoreboard plugin renders skins, so the - # store refuses them rather than installing something that does - # nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and - # uninstall_skin are unaffected. - if (plugin_info.get('type') or 'plugin') == 'skin': - from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE - if not SKINS_RENDER_SUPPORTED: - self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}") - return False - return self._install_skin_from_info(plugin_id, plugin_info, branch) + if not self.is_plugin_entry(plugin_info): + self.logger.error(f"Not installing {plugin_id}: registry entry type " + f"{plugin_info.get('type')!r} is not a plugin") + return False repo_url = plugin_info.get('repo') if not repo_url: @@ -2479,152 +2482,6 @@ class PluginStoreManager: continue return None - _SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') - - def _resolve_skin_target(self, skin_id: str) -> Optional[Path]: - """Validate an externally-supplied skin id and resolve it to a path - strictly inside the skins directory. Returns None (after logging) - for ids that are malformed or would escape the directory — registry - entries and manifests are external input and must not be able to - write or delete outside skins/.""" - from src.skin_system import skin_runtime - - if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \ - or '..' in skin_id: - self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}") - return None - skins_dir = skin_runtime.get_skins_directory().resolve() - target = (skins_dir / skin_id).resolve() - if target.parent != skins_dir: - self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting") - return None - return target - - def _install_skin_from_info(self, skin_id: str, skin_info: Dict, - branch: Optional[str] = None) -> bool: - """Install a registry entry of type "skin" into skins//. - - Reuses the plugin download machinery (git / monorepo zip / archive) - but validates skin.json instead of manifest.json and never installs - dependencies — skins are render-only (stdlib + PIL + the provided - SkinContext), which is also what keeps them safe to iterate on. - - Downloads into a staging directory and validates there; the - existing installation is only replaced after the new one passes, - so a failed download or bad manifest can't destroy a working skin. - """ - from src.skin_system import skin_runtime - from src.skin_system.skin_base import SKIN_API_VERSION - - repo_url = skin_info.get('repo') - if not repo_url: - self.logger.error(f"Skin {skin_id} missing repository URL") - return False - - target = self._resolve_skin_target(skin_id) - if target is None: - return False - skins_dir = target.parent - skins_dir.mkdir(parents=True, exist_ok=True) - # Leading "_" keeps staging invisible to skin discovery - staging = skins_dir / f"_staging-{skin_id}" - if staging.exists() and not self._safe_remove_directory(staging): - return False - - subpath = skin_info.get('plugin_path') - branch_candidates = self._distinct_sequence([ - branch, - skin_info.get('branch'), - skin_info.get('default_branch'), - skin_info.get('last_commit_branch'), - 'main', - 'master' - ]) - - try: - branch_used = None - if subpath: - for candidate in branch_candidates: - download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_from_monorepo(download_url, subpath, staging): - branch_used = candidate - break - else: - branch_used = self._install_via_git(repo_url, staging, branch_candidates) - if branch_used is None and not staging.exists(): - for candidate in branch_candidates: - download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_via_download(download_url, staging): - branch_used = candidate - break - - if branch_used is None and not staging.exists(): - self.logger.error(f"Failed to install skin {skin_id} via git or archive download") - return False - - try: - with open(staging / 'skin.json', 'r', encoding='utf-8') as f: - manifest = json.load(f) - except (OSError, json.JSONDecodeError) as e: - self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}") - return False - - missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name') - if not manifest.get(k)] - if missing: - self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}") - return False - - # Unlike plugins, a mismatched id is rejected rather than - # renamed: the manifest id is external input, and the registry - # id is what the user asked to install. - if manifest['id'] != skin_id: - self.logger.error( - f"Skin manifest id {manifest['id']!r} doesn't match registry id " - f"{skin_id!r}; not installing") - return False - - def _api_major(v): - try: - return int(str(v).split('.')[0]) - except (ValueError, IndexError): - return None - - if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION): - self.logger.error( - f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this " - f"LEDMatrix provides {SKIN_API_VERSION}; not installing") - return False - - # Validated — swap into place - if target.exists() and not self._safe_remove_directory(target): - self.logger.error(f"Could not replace existing skin directory: {target}") - return False - shutil.move(str(staging), str(target)) - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})") - return True - finally: - if staging.exists(): - self._safe_remove_directory(staging) - - def uninstall_skin(self, skin_id: str) -> bool: - """Remove an installed skin. Plugin configs referencing it keep - validating; rendering falls back to the built-in layout.""" - from src.skin_system import skin_runtime - - target = self._resolve_skin_target(skin_id) - if target is None: - return False - if not target.exists(): - self.logger.info(f"Skin {skin_id} not found (already uninstalled)") - return True - if self._safe_remove_directory(target): - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully uninstalled skin: {skin_id}") - return True - return False - def uninstall_plugin(self, plugin_id: str) -> bool: """ Uninstall a plugin by removing its directory. @@ -2638,12 +2495,6 @@ class PluginStoreManager: plugin_path = self._find_plugin_path(plugin_id) if plugin_path is None or not plugin_path.exists(): - # A skin id passed to the plugin uninstall path (the store UI - # uses one uninstall flow) removes the skin instead - skin_target = self._resolve_skin_target(plugin_id) \ - if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None - if skin_target is not None and skin_target.exists(): - return self.uninstall_skin(plugin_id) self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)") return True # Already uninstalled, consider this success diff --git a/src/skin_system/__init__.py b/src/skin_system/__init__.py deleted file mode 100644 index 422358da..00000000 --- a/src/skin_system/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Skin system: user-installable visual overlays for sports scoreboards. - -A skin replaces only the rendering of a scoreboard (live / recent / -upcoming) while the host plugin keeps doing data fetching, scheduling, -caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md. -""" - -# Skins are not offered to users yet. The only render hook is -# SportsCore._render_game in src/base_classes/sports/core.py, and none of the -# current scoreboard plugins (monorepo or third-party) build on -# src.base_classes, so a selected skin never draws. The web UI and store -# read these instead of offering install/selection; stored "skin" config -# values still load and save. See docs/SKIN_SYSTEM.md. -SKINS_RENDER_SUPPORTED = False -SKINS_UNSUPPORTED_MESSAGE = ( - "Skins aren't supported yet: the current scoreboard plugins don't render " - "them. Installed skins and saved skin settings are kept but have no effect." -) - -from src.skin_system.skin_base import ( # noqa: E402 - SKIN_API_VERSION, - VIEW_MODEL_VERSION, - ScoreboardSkin, - SkinContext, -) -from src.skin_system.skin_runtime import ( - build_context, - discover_skins, - get_skins_directory, - load_skin, -) - -__all__ = [ - "SKIN_API_VERSION", - "VIEW_MODEL_VERSION", - "ScoreboardSkin", - "SkinContext", - "build_context", - "discover_skins", - "get_skins_directory", - "load_skin", -] diff --git a/src/skin_system/fixtures/baseball_live.json b/src/skin_system/fixtures/baseball_live.json deleted file mode 100644 index c5bcec34..00000000 --- a/src/skin_system/fixtures/baseball_live.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Bot 7th", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_IN_PROGRESS", - "status_state": "in", - "inning": 7, - "inning_half": "bottom", - "balls": 3, - "strikes": 2, - "outs": 2, - "bases_occupied": [ - true, - true, - true - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "LAD leads 2-1" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_recent.json b/src/skin_system/fixtures/baseball_recent.json deleted file mode 100644 index 15ed238f..00000000 --- a/src/skin_system/fixtures/baseball_recent.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_FINAL", - "status_state": "post", - "inning": 9, - "inning_half": "top", - "balls": 0, - "strikes": 0, - "outs": 3, - "bases_occupied": [ - false, - false, - false - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "Series tied 2-2" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_upcoming.json b/src/skin_system/fixtures/baseball_upcoming.json deleted file mode 100644 index 48ccb857..00000000 --- a/src/skin_system/fixtures/baseball_upcoming.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_SCHEDULED", - "status_state": "pre", - "inning": 0, - "inning_half": "top", - "balls": 0, - "strikes": 0, - "outs": 0, - "bases_occupied": [ - false, - false, - false - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_live.json b/src/skin_system/fixtures/basketball_live.json deleted file mode 100644 index 06e86880..00000000 --- a/src/skin_system/fixtures/basketball_live.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Q4 2:34", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 4, - "period_text": "Q4", - "clock": "2:34" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_recent.json b/src/skin_system/fixtures/basketball_recent.json deleted file mode 100644 index 96e15d06..00000000 --- a/src/skin_system/fixtures/basketball_recent.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 4, - "period_text": "Final", - "clock": "0:00" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_upcoming.json b/src/skin_system/fixtures/basketball_upcoming.json deleted file mode 100644 index 32cd7df6..00000000 --- a/src/skin_system/fixtures/basketball_upcoming.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_live.json b/src/skin_system/fixtures/football_live.json deleted file mode 100644 index b680e640..00000000 --- a/src/skin_system/fixtures/football_live.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Q3 8:12", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "21", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "17", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 3, - "period_text": "Q3", - "clock": "8:12", - "home_timeouts": 2, - "away_timeouts": 3, - "down_distance_text": "3rd & 4", - "down_distance_text_long": "3rd & 4 at KC 22", - "is_redzone": true, - "possession": "12", - "possession_indicator": "away", - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_recent.json b/src/skin_system/fixtures/football_recent.json deleted file mode 100644 index 1f2efb5c..00000000 --- a/src/skin_system/fixtures/football_recent.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "21", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "17", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 4, - "period_text": "Final", - "clock": "0:00", - "home_timeouts": 0, - "away_timeouts": 0, - "down_distance_text": "", - "down_distance_text_long": "", - "is_redzone": false, - "possession": null, - "possession_indicator": null, - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_upcoming.json b/src/skin_system/fixtures/football_upcoming.json deleted file mode 100644 index 7b28c0a9..00000000 --- a/src/skin_system/fixtures/football_upcoming.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00", - "home_timeouts": 3, - "away_timeouts": 3, - "down_distance_text": "", - "down_distance_text_long": "", - "is_redzone": false, - "possession": null, - "possession_indicator": null, - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_live.json b/src/skin_system/fixtures/hockey_live.json deleted file mode 100644 index 2d53425d..00000000 --- a/src/skin_system/fixtures/hockey_live.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "P3 14:55", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "2", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "2", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 3, - "period_text": "P3", - "clock": "14:55", - "power_play": true, - "penalties": [], - "home_shots": 27, - "away_shots": 31 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_recent.json b/src/skin_system/fixtures/hockey_recent.json deleted file mode 100644 index 0127162a..00000000 --- a/src/skin_system/fixtures/hockey_recent.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final/OT", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "3", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "2", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 5, - "period_text": "Final/OT", - "clock": "0:00", - "power_play": false, - "penalties": [], - "home_shots": 35, - "away_shots": 33 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_upcoming.json b/src/skin_system/fixtures/hockey_upcoming.json deleted file mode 100644 index 64caf089..00000000 --- a/src/skin_system/fixtures/hockey_upcoming.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00", - "power_play": false, - "penalties": [], - "home_shots": 0, - "away_shots": 0 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/placeholder_away.png b/src/skin_system/fixtures/placeholder_away.png deleted file mode 100644 index fc9ec4ee4f940bec40e58d063d29060d8d0ff268..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 444 zcmV;t0YmOcVuP{Q^@l4TkzVpNnAmTcVaY=;Z z2JWzQa-!JOQwTMOt+60Q?5SzMThoBI)CQ2tttHmU0CIUmi(=aiXbzSTS5@0H1UBN8k`r0DcQ{hXRXeGx*1#Q>)D{D20C|Ig z634`5z*5jCNe+3dQ^?bV^n|*(4|tg9IbIjf_0bm`A?R*t#5-{wa6~q=S4{tP$(QS~ m*Q});d(lnF<5PnvZ~XwytJ_ej<6Qax00002qnDW(v3>IUIqCuAhHm*64 z6q*Gvno)P=z@M*gwcqFcph1*Ix;vHNE#Q_)L%l}28t3p<@KU*v9uqfd!;$c1%$~Xu zIZXjafKy7@kYu8nfurH`u_T$O8t{NmlA2%*c)}-1Oppewo6`t!Ow}pm`Shg>yDCRa z9d5Tp#4J~)yE|NJwQZBQf)?+@Xdq1=N@fEV5QK@;Vv8ksJY7SdtL5kQ@(}1_80dJ`dAeUQ9tepYm@`x72wi?hJEFrR>iIKCK zYrtXx@c`(4Qdg~kGc2hs2GRiX z1_dRKiOqnepiz<>@>ZvirwQo^b#ou^Fwt|oE}rY7FE~Qb-O`A6;ymDpY-q2T{_B!2 o*JZC+OI`M&o07+;22/ containing a skin.json -manifest and a Python module exposing a ScoreboardSkin subclass. The -host (a sports scoreboard's base classes) builds a SkinContext per -render and calls render_live / render_recent / render_upcoming with the -game view model. The skin draws onto ctx.canvas and returns True; the -host composites the canvas onto the display. A skin never talks to the -display, the network, or the plugin directly. - -Skin API Version: 1.0.0 -View Model Version: 1.0 -""" - -from abc import ABC -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Optional, Tuple, Union - -from PIL import Image, ImageDraw - -try: - import freetype -except ImportError: # pragma: no cover - freetype ships with the project deps - freetype = None - -from src.adaptive_layout import FitResult, LayoutContext, Region - -# Major must match a skin manifest's skin_api_version major or the skin -# is refused at load time (renames/removals bump major; additions minor). -SKIN_API_VERSION = "1.0.0" - -# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md). -VIEW_MODEL_VERSION = "1.0" - - -def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int, - color: Tuple[int, int, int], face: Any, - clip_w: int, clip_h: int) -> None: - """Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas. - - DisplayManager._draw_bdf_text only draws onto the panel image; skins - draw onto their own canvas, so the fitted-font path (fit_text can - return freetype faces) needs this standalone equivalent. - """ - try: - ascender_px = face.size.ascender >> 6 - except Exception: - ascender_px = 0 - baseline_y = y + ascender_px - for char in text: - face.load_char(char) - bitmap = face.glyph.bitmap - glyph_left = face.glyph.bitmap_left - glyph_top = face.glyph.bitmap_top - for i in range(bitmap.rows): - for j in range(bitmap.width): - byte_index = i * bitmap.pitch + (j // 8) - if byte_index < len(bitmap.buffer) and \ - bitmap.buffer[byte_index] & (1 << (7 - (j % 8))): - px = x + glyph_left + j - py = baseline_y - glyph_top + i - if 0 <= px < clip_w and 0 <= py < clip_h: - draw.point((px, py), fill=color) - x += face.glyph.advance.x >> 6 - - -@dataclass -class SkinContext: - """Everything a skin may touch during one render call. - - The canvas is a fresh RGB image sized to the current display (or - vegas card). Draw onto it via the helpers below or raw ``draw``; - never call display/update methods — the host composites the canvas. - """ - - canvas: Image.Image - draw: ImageDraw.ImageDraw - layout: LayoutContext - width: int - height: int - fonts: Dict[str, Any] - options: Dict[str, Any] - logger: Any - sport: Optional[str] = None - view_model_version: str = VIEW_MODEL_VERSION - # load_logo("home") / load_logo("away") -> RGBA PIL image or None. - # Bound to the current game; hits the host's logo cache (never loads - # from disk twice), downloads missing logos like the built-in layout. - load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None) - # draw_text_outlined(text, (x, y), font, fill=..., outline_color=...) - # — the classic scorebug outlined text, drawn onto this canvas. - # TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts - # use draw_fit / draw_text, which handle BDF faces too. - draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None) - - def draw_text(self, text: str, x: int, y: int, - color: Tuple[int, int, int] = (255, 255, 255), - font: Any = None) -> None: - """Draw text at a top-left position, handling both PIL fonts and - the freetype BDF faces that layout.fit_text can return.""" - if font is None: - font = self.fonts.get('time') - if freetype is not None and isinstance(font, freetype.Face): - _draw_bdf_text_on(self.draw, text, int(x), int(y), color, font, - self.width, self.height) - else: - self.draw.text((int(x), int(y)), text, font=font, fill=color) - - def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]], - color: Tuple[int, int, int] = (255, 255, 255), - align: str = "center", valign: str = "center") -> None: - """Draw a layout.fit_text() result aligned within a Region — the - canvas-local equivalent of adaptive_layout.draw_fitted_text.""" - region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1]) - x, y = region.align_xy(fit.width, fit.height, align, valign) - self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font) - - def draw_image(self, img: Optional[Image.Image], - box: Union[Region, Tuple[int, int]], *, - mode: str = "contain", align: str = "center", - valign: str = "center", cache_key: Any = None) -> None: - """Fit an image (a logo, art) into a Region and paste it, honoring - alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo( - 'home'), ...)` stays safe when a logo is missing.""" - if img is None: - return - region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1]) - fitted = self.layout.fit_image(img, region, mode=mode, - cache_key=cache_key) - result = fitted.image # fit_image returns an ImageFitResult (always RGBA) - if result is None: - return - x, y = region.align_xy(result.width, result.height, align, valign) - self.canvas.paste(result, (int(x), int(y)), result) - - -class ScoreboardSkin(ABC): - """Base class for scoreboard skins. - - Override only the modes you want to restyle; any mode you leave - unimplemented (or return False from) falls back to the plugin's - built-in renderer, so a live-only skin still gets recent/upcoming - screens for free. - - Skins should be stateless: three host instances (live, recent, - upcoming) each hold their own skin instance, and a render must be - derivable from (ctx, game) alone. - """ - - SKIN_API_VERSION = SKIN_API_VERSION - - def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]): - self.manifest = manifest - self.options = options or {} - - def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_vegas_card(self, ctx: SkinContext, - game: Dict[str, Any]) -> Optional[Image.Image]: - """Render one vegas scroll card at ctx.width x ctx.height. Return - the finished image, or None to let the host use its default vegas - rendering (which captures the regular display output).""" - return None diff --git a/src/skin_system/skin_runtime.py b/src/skin_system/skin_runtime.py deleted file mode 100644 index 6c923eb0..00000000 --- a/src/skin_system/skin_runtime.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Skin runtime: discovery, validation, loading, and context building. - -Deliberately generic — this module knows nothing about sports beyond -passing a `sport` label through; the sports flavor lives in skin_base -(ScoreboardSkin) and in the hosts that call build_context. - -Every failure path here logs and returns None: a broken or missing skin -must never take down the plugin that references it — the host falls -back to its built-in renderer. -""" - -import importlib.util -import json -import sys -import threading -from pathlib import Path -from typing import Any, Dict, Optional, Tuple - -from PIL import Image, ImageDraw - -from src.adaptive_layout import LayoutContext -from src.logging_config import get_logger -from src.skin_system.skin_base import ( - SKIN_API_VERSION, - ScoreboardSkin, - SkinContext, -) - -logger = get_logger(__name__) - -_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name") -_DEFAULT_ENTRY_POINT = "skin.py" - -_lock = threading.RLock() -# skins_dir -> (fingerprint, {skin_id: manifest+path}) -_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {} - -_shared_layout_font_manager: Optional[Any] = None - - -def _get_font_manager() -> Any: - """Shared FontManager for skin LayoutContexts. SportsCore hosts don't - carry a plugin_manager, so skins share one module-level FontManager — - the same shape as base_plugin._fallback_font_manager, constructed - directly so rendering never has to import the whole plugin system.""" - global _shared_layout_font_manager - if _shared_layout_font_manager is None: - from src.font_manager import FontManager - _shared_layout_font_manager = FontManager({}) - return _shared_layout_font_manager - - -def get_skins_directory() -> Path: - """Central skins directory: /skins. Lives outside the - plugin directories on purpose — plugin reinstall/update deletes the - whole plugin directory, and a skin must survive that.""" - return Path(__file__).resolve().parents[2] / "skins" - - -def _major(version: str) -> Optional[int]: - try: - return int(str(version).split(".")[0]) - except (ValueError, AttributeError, IndexError): - return None - - -def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]: - manifest_path = skin_dir / "skin.json" - if not manifest_path.is_file(): - return None - try: - with open(manifest_path, "r", encoding="utf-8") as f: - manifest = json.load(f) - except (OSError, json.JSONDecodeError) as e: - logger.error("Skin manifest %s is unreadable: %s", manifest_path, e) - return None - missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)] - if missing: - logger.error("Skin manifest %s missing required fields: %s", - manifest_path, ", ".join(missing)) - return None - if manifest["id"] != skin_dir.name: - logger.warning("Skin manifest id %r does not match directory name %r", - manifest["id"], skin_dir.name) - manifest["_skin_dir"] = str(skin_dir) - return manifest - - -def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]: - """Cache key for a skins directory: its mtime plus every skin.json's - (path, mtime). The directory mtime alone misses in-place manifest edits - (a skin updated without adding/removing entries).""" - try: - parts = [skins_dir.stat().st_mtime] - for manifest_path in sorted(skins_dir.glob("*/skin.json")): - parts.append((str(manifest_path), manifest_path.stat().st_mtime)) - return tuple(parts) - except OSError: - return None - - -def discover_skins(skins_dir: Optional[Path] = None, - force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: - """Return {skin_id: manifest} for every valid skin package installed. - - Cached per directory and invalidated when the directory or any - skin.json changes; pass force_refresh to bypass. - """ - skins_dir = Path(skins_dir) if skins_dir else get_skins_directory() - cache_key = str(skins_dir) - fingerprint = _discovery_fingerprint(skins_dir) - if fingerprint is None: - return {} - - with _lock: - cached = _discovery_cache.get(cache_key) - if cached and not force_refresh and cached[0] == fingerprint: - return dict(cached[1]) - - skins: Dict[str, Dict[str, Any]] = {} - for entry in sorted(skins_dir.iterdir()): - if not entry.is_dir() or entry.name.startswith((".", "_")): - continue - manifest = _read_manifest(entry) - if manifest: - skins[manifest["id"]] = manifest - _discovery_cache[cache_key] = (fingerprint, skins) - return dict(skins) - - -def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]: - """(sports, sport_keys) a skin declares it supports.""" - targets = manifest.get("targets") or {} - return (list(targets.get("sports") or []), - list(targets.get("sport_keys") or [])) - - -def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str], - sport_key: Optional[str]) -> bool: - """True when the skin declares support for this sport family or exact - sport key. A skin with no targets at all matches everything.""" - sports, sport_keys = skin_targets(manifest) - if not sports and not sport_keys: - return True - if sport and sport in sports: - return True - if sport_key and sport_key in sport_keys: - return True - return False - - -def skins_for_plugin(plugin_id: str, - skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]: - """Installed skins that plausibly apply to a plugin, for UI dropdowns. - - A skin matches when the plugin id is listed in targets.plugins, or any - declared sport / sport_key appears as a token of the plugin id (so a - skin targeting sports=["baseball"] matches "baseball-scoreboard", and - sport_keys=["milb"] matches "milb-scoreboard").""" - if skins is None: - skins = discover_skins() - tokens = set(str(plugin_id).lower().replace("-", "_").split("_")) - matched = {} - for skin_id, manifest in skins.items(): - targets = manifest.get("targets") or {} - if plugin_id in (targets.get("plugins") or []): - matched[skin_id] = manifest - continue - sports, sport_keys = skin_targets(manifest) - if any(str(t).lower() in tokens for t in sports + sport_keys): - matched[skin_id] = manifest - return matched - - -def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]: - """Import the skin's entry module under a namespaced sys.modules key, - namespacing its sibling .py files the same way — the collision- - avoidance scheme plugins use (plugin_loader._namespace_plugin_modules), - so two skins can both ship a helpers.py. - - The entry module is cached: the live/recent/upcoming hosts all load - the same skin, and only the first load executes any code. (A skin - whose *code* changed on disk needs a service restart to take effect — - Python modules can't be safely hot-swapped.) - """ - entry_path = skin_dir / entry_point - if not entry_path.is_file(): - logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path) - return None - - module_name = f"_skin_{skin_id}_{Path(entry_point).stem}" - with _lock: - cached_entry = sys.modules.get(module_name) - if cached_entry is not None: - return cached_entry - - # Import siblings under their namespaced alias, and *bind* the bare - # name (cached or fresh) so `import helpers` inside the entry module - # resolves to this skin's copy. The bare bindings are transient — - # restored below so another skin's identically-named sibling can't - # be shadowed by ours. - replaced_bare: Dict[str, Any] = {} - try: - for sibling in skin_dir.glob("*.py"): - if sibling.name == entry_point: - continue - alias = f"_skin_{skin_id}_{sibling.stem}" - module = sys.modules.get(alias) - if module is None: - spec = importlib.util.spec_from_file_location(alias, sibling) - if not spec or not spec.loader: - continue - module = importlib.util.module_from_spec(spec) - sys.modules[alias] = module - replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) - sys.modules[sibling.stem] = module - try: - spec.loader.exec_module(module) - except Exception as e: - logger.error("Skin '%s' sibling module %s failed to import: %s", - skin_id, sibling.name, e, exc_info=True) - sys.modules.pop(alias, None) - return None - else: - replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) - sys.modules[sibling.stem] = module - - try: - spec = importlib.util.spec_from_file_location(module_name, entry_path) - if not spec or not spec.loader: - logger.error("Skin '%s': could not create import spec for %s", - skin_id, entry_path) - return None - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - except Exception as e: - sys.modules.pop(module_name, None) - logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True) - return None - finally: - for bare_name, previous in replaced_bare.items(): - if previous is None: - sys.modules.pop(bare_name, None) - else: - sys.modules[bare_name] = previous - - -def load_skin(skin_id: str, sport: Optional[str] = None, - sport_key: Optional[str] = None, - options: Optional[Dict[str, Any]] = None, - skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]: - """Load and instantiate a skin. Returns None (after logging why) on - any failure — callers treat None as 'use the built-in renderer'.""" - skins = discover_skins(skins_dir) - manifest = skins.get(skin_id) - if manifest is None: - logger.warning("Skin '%s' is configured but not installed under %s; " - "using built-in renderer", - skin_id, skins_dir or get_skins_directory()) - return None - - manifest_major = _major(manifest.get("skin_api_version")) - api_major = _major(SKIN_API_VERSION) - if manifest_major != api_major: - logger.error("Skin '%s' targets skin API %s but this LEDMatrix " - "provides %s — the skin needs an update; using " - "built-in renderer", - skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION) - return None - - if not skin_matches_target(manifest, sport, sport_key): - # Soft: the user explicitly configured it, so warn but load anyway - # (a baseball skin may render an acceptable generic scoreboard). - logger.warning("Skin '%s' does not declare support for sport=%r / " - "sport_key=%r; loading anyway", skin_id, sport, sport_key) - - skin_dir = Path(manifest["_skin_dir"]) - module = _load_skin_module(skin_id, skin_dir, - manifest.get("entry_point", _DEFAULT_ENTRY_POINT)) - if module is None: - return None - - class_name = manifest["class_name"] - skin_class = getattr(module, class_name, None) - if skin_class is None or not isinstance(skin_class, type) or \ - not issubclass(skin_class, ScoreboardSkin): - logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass", - skin_id, class_name) - return None - - try: - return skin_class(manifest, options or {}) - except Exception as e: - logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True) - return None - - -def build_context(host: Any, game: Dict[str, Any], - size: Optional[Tuple[int, int]] = None) -> SkinContext: - """Build a SkinContext for one render call. - - `host` is a SportsCore-style object: display_manager, fonts, logger, - sport, skin_options, _load_and_resize_logo, _draw_text_with_outline. - `size` overrides the canvas size (vegas cards); default is the - current display size read live from the display manager. - """ - if size is not None: - width, height = int(size[0]), int(size[1]) - else: - dm = host.display_manager - width = getattr(dm, "width", None) or dm.matrix.width - height = getattr(dm, "height", None) or dm.matrix.height - - canvas = Image.new("RGB", (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(canvas) - layout = LayoutContext(width, height, _get_font_manager()) - - def load_logo(side: str) -> Optional[Image.Image]: - if side not in ("home", "away"): - return None - try: - logo_path = game.get(f"{side}_logo_path") - if logo_path is not None and not isinstance(logo_path, Path): - logo_path = Path(logo_path) - return host._load_and_resize_logo( - game.get(f"{side}_id"), game.get(f"{side}_abbr"), - logo_path, game.get(f"{side}_logo_url")) - except Exception as e: - host.logger.warning("Skin logo load failed for %s: %s", side, e) - return None - - def draw_text_outlined(text, position, font, fill=(255, 255, 255), - outline_color=(0, 0, 0)): - host._draw_text_with_outline(draw, text, position, font, - fill=fill, outline_color=outline_color) - - return SkinContext( - canvas=canvas, - draw=draw, - layout=layout, - width=width, - height=height, - fonts=dict(host.fonts), - options=dict(getattr(host, "skin_options", {}) or {}), - logger=host.logger, - sport=getattr(host, "sport", None), - load_logo=load_logo, - draw_text_outlined=draw_text_outlined, - ) diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index 7c2b46a0..cb2a37f6 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -639,15 +639,6 @@ "POST" ] ], - [ - "/api/v3/skins", - "api_v3.list_skins", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], [ "/api/v3/starlark/apps", "api_v3.get_starlark_apps", diff --git a/test/test_api_extractors.py b/test/test_api_extractors.py deleted file mode 100644 index cced61af..00000000 --- a/test/test_api_extractors.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Tests for src/base_classes/api_extractors.py - -Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor, -SoccerAPIExtractor, and the shared _extract_common_details logic. -""" - -import logging -import pytest -from src.base_classes.api_extractors import ( - ESPNFootballExtractor, - ESPNBaseballExtractor, - ESPNHockeyExtractor, - SoccerAPIExtractor, -) - - -# --------------------------------------------------------------------------- -# Shared test data factories -# --------------------------------------------------------------------------- - -def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF", - home_score: str = "14", away_score: str = "7", - date_str: str = "2024-01-15T20:00:00Z", - include_situation: bool = False, - situation: dict | None = None, - status_detail: str = "2nd Qtr 8:42", - period: int = 2) -> dict: - """Build a minimal ESPN-style game event dict.""" - comp_status = { - "type": { - "state": state, - "shortDetail": status_detail, - "detail": status_detail, - "name": "STATUS_IN_PROGRESS", - }, - "period": period, - "displayClock": "8:42", - } - comp = { - "status": comp_status, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"}, - "score": home_score, - }, - { - "homeAway": "away", - "team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"}, - "score": away_score, - }, - ], - } - if include_situation: - comp["situation"] = situation or {} - return { - "id": "test-game-1", - "date": date_str, - "competitions": [comp], - } - - -def _make_logger() -> logging.Logger: - return logging.getLogger("test_extractor") - - -# --------------------------------------------------------------------------- -# ESPNFootballExtractor -# --------------------------------------------------------------------------- - -class TestESPNFootballExtractor: - def setup_method(self): - self.extractor = ESPNFootballExtractor(_make_logger()) - - def test_extract_live_game_basic_fields(self): - event = _make_espn_event(state="in", home_score="14", away_score="7") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "KC" - assert result["away_abbr"] == "BUF" - assert result["home_score"] == "14" - assert result["away_score"] == "7" - assert result["is_live"] is True - assert result["is_final"] is False - assert result["is_upcoming"] is False - - def test_extract_final_game(self): - event = _make_espn_event(state="post") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_final"] is True - assert result["is_live"] is False - - def test_extract_upcoming_game(self): - event = _make_espn_event(state="pre") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_upcoming"] is True - - def test_sport_specific_fields_default_when_pregame(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "down" in fields - assert "distance" in fields - assert "possession" in fields - assert "is_redzone" in fields - assert fields["is_redzone"] is False - - def test_sport_specific_fields_live_with_situation(self): - situation = { - "down": 3, - "distance": 7, - "possession": "KC", - "isRedZone": True, - "homeTimeouts": 2, - "awayTimeouts": 1, - } - event = _make_espn_event(state="in", include_situation=True, situation=situation) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["down"] == 3 - assert fields["distance"] == 7 - assert fields["is_redzone"] is True - assert fields["home_timeouts"] == 2 - assert fields["away_timeouts"] == 1 - - def test_scoring_event_detected(self): - # situation must be non-empty (truthy) for the live block to execute - situation = {"down": 1, "distance": 10} - event = _make_espn_event( - state="in", - include_situation=True, - situation=situation, - status_detail="touchdown scored", - ) - fields = self.extractor.get_sport_specific_fields(event) - assert "touchdown" in fields.get("scoring_event", "").lower() - - def test_returns_none_on_empty_event(self): - assert self.extractor.extract_game_details({}) is None - - def test_returns_none_when_teams_missing(self): - event = { - "id": "x", - "date": "2024-01-15T20:00:00Z", - "competitions": [ - { - "status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}}, - "competitors": [], # no competitors - } - ], - } - assert self.extractor.extract_game_details(event) is None - - def test_date_z_suffix_parsed(self): - event = _make_espn_event(date_str="2024-01-15T20:00:00Z") - result = self.extractor.extract_game_details(event) - # Should not raise and should return a result - assert result is not None - - def test_id_propagated(self): - event = _make_espn_event() - result = self.extractor.extract_game_details(event) - assert result["id"] == "test-game-1" - - -# --------------------------------------------------------------------------- -# ESPNBaseballExtractor -# --------------------------------------------------------------------------- - -class TestESPNBaseballExtractor: - def setup_method(self): - self.extractor = ESPNBaseballExtractor(_make_logger()) - - def test_extract_live_game(self): - event = _make_espn_event( - state="in", home_abbr="NYY", away_abbr="BOS", - home_score="3", away_score="2" - ) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "NYY" - assert result["is_live"] is True - - def test_baseball_sport_fields_defaults(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "inning" in fields - assert "outs" in fields - assert "bases" in fields - assert "strikes" in fields - assert "balls" in fields - - def test_baseball_sport_fields_live(self): - situation = { - "inning": 7, - "outs": 2, - "bases": "110", - "strikes": 2, - "balls": 3, - "pitcher": "Smith", - "batter": "Jones", - } - event = _make_espn_event(state="in", include_situation=True, situation=situation) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["inning"] == 7 - assert fields["outs"] == 2 - assert fields["strikes"] == 2 - assert fields["pitcher"] == "Smith" - - def test_returns_none_on_empty(self): - assert self.extractor.extract_game_details({}) is None - - -# --------------------------------------------------------------------------- -# ESPNHockeyExtractor -# --------------------------------------------------------------------------- - -class TestESPNHockeyExtractor: - def setup_method(self): - self.extractor = ESPNHockeyExtractor(_make_logger()) - - def test_extract_live_game(self): - event = _make_espn_event( - state="in", home_abbr="BOS", away_abbr="TOR", - home_score="2", away_score="1" - ) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_live"] is True - - def test_hockey_period_text_p1(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=1 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P1" - - def test_hockey_period_text_p2(self): - situation = {"isPowerPlay": False} # non-empty so the live block executes - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=2 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P2" - - def test_hockey_period_text_p3(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=3 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P3" - - def test_hockey_period_text_ot(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=4 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "OT1" - - def test_hockey_power_play(self): - situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8} - event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["power_play"] is True - assert fields["shots_on_goal"]["home"] == 12 - assert fields["shots_on_goal"]["away"] == 8 - - def test_hockey_fields_defaults_pregame(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "period" in fields - assert "power_play" in fields - assert fields["power_play"] is False - - def test_returns_none_on_empty(self): - assert self.extractor.extract_game_details({}) is None - - -# --------------------------------------------------------------------------- -# SoccerAPIExtractor -# --------------------------------------------------------------------------- - -class TestSoccerAPIExtractor: - def setup_method(self): - self.extractor = SoccerAPIExtractor(_make_logger()) - - def _make_soccer_event(self, is_live: bool = True) -> dict: - return { - "id": "soccer-1", - "home_team": {"abbreviation": "ARS", "name": "Arsenal"}, - "away_team": {"abbreviation": "CHE", "name": "Chelsea"}, - "home_score": "2", - "away_score": "1", - "status": "LIVE", - "is_live": is_live, - "is_final": not is_live, - "is_upcoming": False, - "half": "1", - "stoppage_time": "2", - "home_yellow_cards": 1, - "away_yellow_cards": 2, - "home_red_cards": 0, - "away_red_cards": 0, - "home_possession": 55, - "away_possession": 45, - } - - def test_extract_live_game(self): - event = self._make_soccer_event(is_live=True) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "ARS" - assert result["away_abbr"] == "CHE" - assert result["is_live"] is True - - def test_sport_specific_cards(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["cards"]["home_yellow"] == 1 - assert fields["cards"]["away_yellow"] == 2 - assert fields["cards"]["home_red"] == 0 - - def test_sport_specific_possession(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["possession"]["home"] == 55 - assert fields["possession"]["away"] == 45 - - def test_sport_specific_half(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["half"] == "1" - - def test_scores_as_strings(self): - event = self._make_soccer_event() - result = self.extractor.extract_game_details(event) - assert result["home_score"] == "2" - assert result["away_score"] == "1" diff --git a/test/test_common_is_hardware_free.py b/test/test_common_is_hardware_free.py index 72cdf512..f385e675 100644 --- a/test/test_common_is_hardware_free.py +++ b/test/test_common_is_hardware_free.py @@ -1,9 +1,8 @@ """src/common must stay importable without display hardware. Plugins import src.common.sports_* in place of their bundled copies. If any of -those modules reaches src.display_manager (and through it rgbmatrix) or the -src.base_classes package (whose core.py imports DisplayManager), a scoreboard -adopting it acquires a hardware dependency it never had, and the headless +those modules reaches src.display_manager (and through it rgbmatrix), a +scoreboard adopting it acquires a hardware dependency it never had, and the headless tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops being able to load it. @@ -26,7 +25,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] COMMON = REPO_ROOT / "src" / "common" -FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system") +FORBIDDEN = ("src.display_manager", "src.plugin_system") #: Existing module-level violations, by file name, each with the reason it is #: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers): @@ -124,13 +123,13 @@ def test_the_scan_sees_a_direct_import(tmp_path): except ImportError: pass if True: - import src.base_classes.sports + import src.plugin_system.plugin_manager from src import plugin_system def later(): from src.display_manager import DisplayManager """)) targets = {t for n in _module_level_imports(tree) for t in _targets(n)} - assert {"src.base_classes.sports", "src.display_manager", + assert {"src.plugin_system.plugin_manager", "src.display_manager", "src.plugin_system"} <= targets diff --git a/test/test_data_sources.py b/test/test_data_sources.py deleted file mode 100644 index d7366923..00000000 --- a/test/test_data_sources.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Tests for src/base_classes/data_sources.py - -Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource. -All HTTP calls are mocked to avoid network access. -""" - -import logging -from datetime import datetime, date -from unittest.mock import MagicMock, patch, Mock -import pytest -import requests - -from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource - - -def _make_logger() -> logging.Logger: - return logging.getLogger("test_data_sources") - - -def _mock_response(json_data: dict, status_code: int = 200): - resp = Mock(spec=requests.Response) - resp.status_code = status_code - resp.json.return_value = json_data - resp.raise_for_status = Mock() - if status_code >= 400: - resp.raise_for_status.side_effect = requests.HTTPError(response=resp) - return resp - - -# --------------------------------------------------------------------------- -# ESPNDataSource -# --------------------------------------------------------------------------- - -class TestESPNDataSource: - def setup_method(self): - self.source = ESPNDataSource(_make_logger()) - - def test_get_headers(self): - headers = self.source.get_headers() - assert headers["Accept"] == "application/json" - assert "LEDMatrix" in headers["User-Agent"] - - def test_fetch_live_games_returns_live_events(self): - live_event = { - "competitions": [{"status": {"type": {"state": "in"}}}] - } - non_live_event = { - "competitions": [{"status": {"type": {"state": "pre"}}}] - } - payload = {"events": [live_event, non_live_event]} - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("football", "nfl") - - assert len(result) == 1 - assert result[0] is live_event - - def test_fetch_live_games_empty_when_none_live(self): - payload = {"events": [ - {"competitions": [{"status": {"type": {"state": "post"}}}]} - ]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("football", "nfl") - assert result == [] - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("network failure")): - result = self.source.fetch_live_games("football", "nfl") - assert result == [] - - def test_fetch_schedule_returns_all_events(self): - events = [{"id": "1"}, {"id": "2"}] - payload = {"events": events} - start = datetime(2024, 1, 1) - end = datetime(2024, 1, 7) - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("football", "nfl", (start, end)) - - assert len(result) == 2 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("timeout")): - result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("football", "nfl") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - # A transport failure is a RequestException, not a bare Exception. - # The old stand-in passed only because the handler caught everything, - # including bugs in the method under test. - with patch.object(self.source.session, "get", - side_effect=requests.ConnectionError("error")): - result = self.source.fetch_standings("football", "nfl") - assert result == {} - - # ------------------------------------------------------------------ - # fetch_standings endpoint selection - # - # College leagues publish a poll at /rankings and a records table at - # /standings; professional leagues have only /standings. Probing them in - # the wrong order still returns 200 -- just without a poll in it -- so - # nothing failed and the rank badge simply never appeared. Order is the - # behaviour here, so these tests assert it directly. - # ------------------------------------------------------------------ - - @staticmethod - def _requested_endpoints(mock_get): - """The endpoint names probed, in the order they were requested.""" - return [call.args[0].rsplit("/", 1)[-1] for call in mock_get.call_args_list] - - def test_professional_league_asks_standings_first(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", - return_value=_mock_response(payload)) as mock_get: - result = self.source.fetch_standings("football", "nfl") - assert result == payload - assert self._requested_endpoints(mock_get) == ["standings"] - - def test_college_league_asks_rankings_first(self): - poll = {"rankings": [{"name": "AP Top 25"}]} - with patch.object(self.source.session, "get", - return_value=_mock_response(poll)) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == poll - assert self._requested_endpoints(mock_get) == ["rankings"] - - def test_rankings_200_without_a_poll_falls_through_to_standings(self): - """A 200 is not the same as an answer. - - This is the case the old code could not see: the endpoint responded, - so nothing raised, but the body carried no poll. - """ - empty_poll = _mock_response({"rankings": []}) - table = _mock_response({"standings": [{"entries": []}]}) - with patch.object(self.source.session, "get", - side_effect=[empty_poll, table]) as mock_get: - result = self.source.fetch_standings( - "basketball", "mens-college-basketball") - assert result == {"standings": [{"entries": []}]} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_404_on_the_first_endpoint_falls_through_quietly(self): - missing = _mock_response({}, status_code=404) - table = _mock_response({"standings": []}) - with patch.object(self.source.session, "get", - side_effect=[missing, table]) as mock_get: - result = self.source.fetch_standings("baseball", "college-baseball") - assert result == {"standings": []} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_recovers_from_a_non_404_failure_on_the_first_endpoint(self): - table = _mock_response({"standings": [{"entries": []}]}) - with patch.object(self.source.session, "get", - side_effect=[requests.ConnectionError("reset"), table]) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == {"standings": [{"entries": []}]} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_both_endpoints_failing_returns_empty(self): - with patch.object(self.source.session, "get", - side_effect=requests.ConnectionError("down")) as mock_get: - result = self.source.fetch_standings("football", "nfl") - assert result == {} - assert self._requested_endpoints(mock_get) == ["standings", "rankings"] - - def test_a_non_object_payload_is_treated_as_a_miss(self): - odd = _mock_response(["not", "an", "object"]) - table = _mock_response({"standings": []}) - with patch.object(self.source.session, "get", - side_effect=[odd, table]) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == {"standings": []} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_a_bug_in_this_method_is_not_swallowed_as_a_failed_endpoint(self): - """The guard for the narrowed handler. - - An error raised while reading the payload used to be caught by the - endpoint handler and reported as 'no poll here', which would silently - drop rankings for a league that has them. It must surface instead. - """ - boom = Mock(spec=requests.Response) - boom.status_code = 200 - boom.raise_for_status = Mock() - boom.json.side_effect = TypeError("a bug, not a network failure") - with patch.object(self.source.session, "get", return_value=boom): - with pytest.raises(TypeError): - self.source.fetch_standings("football", "nfl") - - def test_base_url_set_correctly(self): - assert "espn.com" in self.source.base_url - - -# --------------------------------------------------------------------------- -# MLBAPIDataSource -# --------------------------------------------------------------------------- - -class TestMLBAPIDataSource: - def setup_method(self): - self.source = MLBAPIDataSource(_make_logger()) - - def test_fetch_live_games_filters_live(self): - live_game = {"status": {"abstractGameState": "Live"}} - final_game = {"status": {"abstractGameState": "Final"}} - payload = {"dates": [{"games": [live_game, final_game]}]} - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("baseball", "mlb") - - assert len(result) == 1 - assert result[0] is live_game - - def test_fetch_live_games_empty_dates(self): - payload = {"dates": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("baseball", "mlb") - assert result == [] - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_live_games("baseball", "mlb") - assert result == [] - - def test_fetch_schedule_aggregates_all_dates(self): - payload = { - "dates": [ - {"games": [{"id": "1"}, {"id": "2"}]}, - {"games": [{"id": "3"}]}, - ] - } - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now())) - assert len(result) == 3 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"records": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("baseball", "mlb") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_standings("baseball", "mlb") - assert result == {} - - -# --------------------------------------------------------------------------- -# SoccerAPIDataSource -# --------------------------------------------------------------------------- - -class TestSoccerAPIDataSource: - def setup_method(self): - self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123") - - def test_headers_include_api_key(self): - headers = self.source.get_headers() - assert headers["X-Auth-Token"] == "test-key-123" - - def test_headers_without_api_key(self): - source = SoccerAPIDataSource(_make_logger()) - headers = source.get_headers() - assert "X-Auth-Token" not in headers - - def test_fetch_live_games_success(self): - payload = {"matches": [{"id": "m1"}, {"id": "m2"}]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("soccer", "eng.1") - assert len(result) == 2 - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_live_games("soccer", "eng.1") - assert result == [] - - def test_fetch_schedule_success(self): - payload = {"matches": [{"id": "m1"}]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now())) - assert len(result) == 1 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("soccer", "PL") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_standings("soccer", "PL") - assert result == {} diff --git a/test/test_discovery_path_contract.py b/test/test_discovery_path_contract.py index 993797be..6e8e96ca 100644 --- a/test/test_discovery_path_contract.py +++ b/test/test_discovery_path_contract.py @@ -209,30 +209,3 @@ class TestStandaloneBackupContract: assert "'.standalone-backup-'" in pm_text.replace('"', "'") assert ".standalone-backup-" in sm_text - -class TestSkinTargetResolution: - def _store(self, tmp_path): - return PluginStoreManager( - plugins_dir=str(tmp_path / "plugins"), - uninstalled_registry_path=str(tmp_path / "uninstalled.json")) - - def test_valid_skin_id_resolves_inside_skins_dir(self, tmp_path): - from src.skin_system import skin_runtime - store = self._store(tmp_path) - target = store._resolve_skin_target("my-skin") - assert target is not None - assert target.parent == skin_runtime.get_skins_directory().resolve() - - @pytest.mark.parametrize("bad_id", [ - "../evil", - "..", - "a/../../etc", - "/etc/passwd", - "skin/../../outside", - "", - None, - 123, - ]) - def test_traversal_and_malformed_ids_rejected(self, tmp_path, bad_id): - store = self._store(tmp_path) - assert store._resolve_skin_target(bad_id) is None diff --git a/test/test_retired_plugin_config_keys.py b/test/test_retired_plugin_config_keys.py new file mode 100644 index 00000000..7e7df496 --- /dev/null +++ b/test/test_retired_plugin_config_keys.py @@ -0,0 +1,167 @@ +"""``skin`` and ``skin_options`` outlive the skin system in stored configs. + +The skin system was removed, but a config.json written while it existed can +carry ``skin`` / ``skin_options`` in any plugin section. They used to be core +plugin properties; now they are RETIRED_PLUGIN_KEYS, dropped where a section +is prepared or validated. Most plugin schemas set +``"additionalProperties": false``, so without that a device upgraded with such +a config would flag the plugin degraded on every start. + +The web save paths are covered in +test/web_interface/test_plugin_config_json_saves.py (TestRetiredSkinKeys). +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from src.config_manager import ConfigManager +from src.plugin_system.plugin_manager import PluginManager +from src.plugin_system.schema_manager import ( + CORE_PLUGIN_PROPERTIES, + RETIRED_PLUGIN_KEYS, + SchemaManager, + drop_retired_plugin_keys, +) + +PLUGIN_ID = "scoreboard-shaped" + +STRICT_SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "enabled": {"type": "boolean", "default": True}, + "favorite_teams": {"type": "array", "items": {"type": "string"}, + "default": []}, + }, +} + +OLD_SECTION = { + "enabled": True, + "favorite_teams": ["TB"], + "skin": {"live": "neon"}, + "skin_options": {"accent": "#ff0000"}, +} + + +def _copy(value): + return json.loads(json.dumps(value)) + + +class TestDropRetiredPluginKeys: + def test_they_are_no_longer_core_properties(self): + assert RETIRED_PLUGIN_KEYS == {"skin", "skin_options"} + assert RETIRED_PLUGIN_KEYS.isdisjoint(CORE_PLUGIN_PROPERTIES) + + def test_drops_both(self): + assert drop_retired_plugin_keys(OLD_SECTION, STRICT_SCHEMA) == { + "enabled": True, "favorite_teams": ["TB"]} + + def test_does_not_mutate_the_section(self): + section = _copy(OLD_SECTION) + drop_retired_plugin_keys(section, STRICT_SCHEMA) + assert section == OLD_SECTION + + def test_returns_the_same_object_when_there_is_nothing_to_drop(self): + section = {"enabled": True} + assert drop_retired_plugin_keys(section, STRICT_SCHEMA) is section + + def test_a_plugin_that_declares_skin_keeps_it(self): + schema = _copy(STRICT_SCHEMA) + schema["properties"]["skin"] = {"type": "string"} + assert drop_retired_plugin_keys(OLD_SECTION, schema) == { + "enabled": True, "favorite_teams": ["TB"], "skin": {"live": "neon"}} + + def test_without_a_schema_nothing_is_dropped(self): + assert drop_retired_plugin_keys(OLD_SECTION, None) is OLD_SECTION + + def test_tolerates_a_non_dict(self): + assert drop_retired_plugin_keys(None, STRICT_SCHEMA) is None + + +@pytest.fixture +def schema_manager(tmp_path): + plugin_dir = tmp_path / "plugins" / PLUGIN_ID + plugin_dir.mkdir(parents=True) + (plugin_dir / "config_schema.json").write_text(json.dumps(STRICT_SCHEMA), + encoding="utf-8") + (plugin_dir / "manifest.json").write_text(json.dumps({"id": PLUGIN_ID}), + encoding="utf-8") + return SchemaManager(plugins_dir=tmp_path / "plugins", project_root=tmp_path) + + +class TestValidation: + def test_the_prepared_section_has_neither_and_validates(self, schema_manager): + prepared = schema_manager.prepare_plugin_config(PLUGIN_ID, _copy(OLD_SECTION)) + assert "skin" not in prepared and "skin_options" not in prepared + assert prepared["favorite_teams"] == ["TB"] + assert schema_manager.validate_config_against_schema( + prepared, STRICT_SCHEMA, PLUGIN_ID) == (True, []) + + def test_the_raw_section_validates_too(self, schema_manager): + assert schema_manager.validate_config_against_schema( + _copy(OLD_SECTION), STRICT_SCHEMA, PLUGIN_ID) == (True, []) + + def test_validate_all_plugin_configs_passes(self, schema_manager, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"timezone": "UTC", PLUGIN_ID: OLD_SECTION}), + encoding="utf-8") + config_manager = ConfigManager(config_path=str(config_file), + secrets_path=str(tmp_path / "secrets.json")) + results = config_manager.validate_all_plugin_configs(schema_manager) + assert results[PLUGIN_ID] == {"valid": True, "errors": []} + + def test_a_real_violation_is_still_reported(self, schema_manager): + section = dict(OLD_SECTION, not_declared=1) + valid, errors = schema_manager.validate_config_against_schema( + section, STRICT_SCHEMA, PLUGIN_ID) + assert valid is False and len(errors) == 1 + assert "not_declared" in errors[0] + + +class TestLoadPath: + """The real PluginManager.load_plugin, with the plugin class stubbed.""" + + def test_an_old_section_loads_without_warning_or_degraded(self, tmp_path): + plugins_dir = tmp_path / "plugins" + plugin_dir = plugins_dir / PLUGIN_ID + plugin_dir.mkdir(parents=True) + manifest = {"id": PLUGIN_ID, "name": "Scoreboard-shaped", "version": "1.0.0", + "entry_point": "manager.py", "class_name": "Plugin"} + (plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (plugin_dir / "config_schema.json").write_text(json.dumps(STRICT_SCHEMA), + encoding="utf-8") + + config_manager = MagicMock() + config_manager.load_config.return_value = {PLUGIN_ID: _copy(OLD_SECTION)} + with patch('src.common.permission_utils.ensure_directory_permissions'): + manager = PluginManager(plugins_dir=str(plugins_dir), + config_manager=config_manager, + display_manager=MagicMock(), + cache_manager=MagicMock()) + manager.logger = MagicMock() + manager.health_tracker = MagicMock() + manager.plugin_manifests[PLUGIN_ID] = manifest + manager.plugin_loader.find_plugin_directory = MagicMock(return_value=plugin_dir) + + received = {} + + def fake_load_plugin(**kwargs): + received.update(kwargs["config"]) + instance = MagicMock() + instance.validate_config.return_value = True + return instance, MagicMock() + + manager.plugin_loader.load_plugin = MagicMock(side_effect=fake_load_plugin) + + assert manager.load_plugin(PLUGIN_ID) is True + assert "skin" not in received and "skin_options" not in received + assert received["favorite_teams"] == ["TB"] + warnings = [c for c in manager.logger.warning.call_args_list + if "does not match its schema" in str(c.args[0])] + assert warnings == [] + degraded = [c.args for c in manager.health_tracker.set_degraded.call_args_list + if c.args[0] == PLUGIN_ID] + assert degraded, "schema validation never ran" + assert degraded[-1][1] is None diff --git a/test/test_skin_runtime_cache.py b/test/test_skin_runtime_cache.py deleted file mode 100644 index 460e1902..00000000 --- a/test/test_skin_runtime_cache.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache, -module namespacing internals, API gating edge cases, and targeting. - -test/test_skin_system.py already covers discovery validation, load_skin -basics, and build_context — nothing here duplicates those. - -NOTE: every test uses a UNIQUE skin id. load_skin caches the entry -module in sys.modules per skin id and never re-executes it, so reusing -an id across tests would silently serve another test's module. -""" - -import builtins -import json -import os -import sys -import time -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -# skin_runtime -> skin_base can transitively reach hardware modules via -# sports imports in sibling tests' processes; stub the matrix driver -# before importing, matching test_skin_system.py. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.skin_system import skin_runtime -from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin - - -DEFAULT_BODY = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class {cls}(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " return True\n" -) - - -@pytest.fixture(autouse=True) -def _clean_runtime_state(): - """Clear the discovery cache and any skin modules this test creates.""" - skin_runtime._discovery_cache.clear() - before = {k for k in sys.modules if k.startswith("_skin_")} - yield - skin_runtime._discovery_cache.clear() - created = [k for k in sys.modules - if k.startswith("_skin_") and k not in before] - for k in created: - sys.modules.pop(k, None) - - -def make_skin(skins_dir: Path, skin_id: str, *, - api_version: str = SKIN_API_VERSION, - class_name: str = "TestSkin", - body: str = None, - extra_files: dict = None, - entry_point: str = None, - manifest_id: str = None, - manifest_extra: dict = None, - write_entry: bool = True) -> Path: - """Write a skin package directory and return its path.""" - skin_dir = skins_dir / skin_id - skin_dir.mkdir(parents=True, exist_ok=True) - manifest = { - "id": manifest_id or skin_id, - "name": skin_id, - "version": "1.0.0", - "skin_api_version": api_version, - "class_name": class_name, - } - if entry_point: - manifest["entry_point"] = entry_point - manifest.update(manifest_extra or {}) - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - if write_entry: - entry_name = entry_point or "skin.py" - (skin_dir / entry_name).write_text( - body if body is not None else DEFAULT_BODY.format(cls=class_name)) - for name, content in (extra_files or {}).items(): - (skin_dir / name).write_text(content) - return skin_dir - - -def counting_read_manifest(monkeypatch): - """Wrap skin_runtime._read_manifest with a call counter.""" - original = skin_runtime._read_manifest - counter = {"count": 0} - - def wrapper(skin_dir): - counter["count"] += 1 - return original(skin_dir) - - monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper) - return counter - - -def bump_mtime(path: Path, offset: float = 100.0): - """Set a distinct, strictly later mtime so the fingerprint changes.""" - t = time.time() + offset - os.utime(path, (t, t)) - - -# --------------------------------------------------------------------------- -# A. Discovery cache -# --------------------------------------------------------------------------- - -class TestDiscoveryCache: - def test_second_call_serves_cache(self, tmp_path, monkeypatch): - make_skin(tmp_path, "t01-cache-hit") - counter = counting_read_manifest(monkeypatch) - first = skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - assert count_after_first >= 1 - second = skin_runtime.discover_skins(tmp_path) - assert counter["count"] == count_after_first # no re-read - assert second == first - assert "t01-cache-hit" in second - - def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path): - skin_dir = make_skin(tmp_path, "t02-edit") - skins = skin_runtime.discover_skins(tmp_path) - assert skins["t02-edit"]["name"] == "t02-edit" - - manifest_path = skin_dir / "skin.json" - manifest = json.loads(manifest_path.read_text()) - manifest["name"] = "renamed" - manifest_path.write_text(json.dumps(manifest)) - bump_mtime(manifest_path) - - skins = skin_runtime.discover_skins(tmp_path) # no force_refresh - assert skins["t02-edit"]["name"] == "renamed" - - def test_new_skin_dir_invalidates(self, tmp_path): - make_skin(tmp_path, "t03-first") - assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"} - - new_dir = make_skin(tmp_path, "t03-second") - bump_mtime(new_dir / "skin.json") - bump_mtime(tmp_path) - - skins = skin_runtime.discover_skins(tmp_path) # no force_refresh - assert set(skins) == {"t03-first", "t03-second"} - - def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch): - # PIN: the fingerprint only globs */skin.json — editing a skin's - # .py file alone does NOT invalidate the cache; the cached - # manifests are still served (a code change needs a restart). - skin_dir = make_skin(tmp_path, "t04-pyedit") - counter = counting_read_manifest(monkeypatch) - skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - - (skin_dir / "skin.py").write_text("# rewritten\n" + - DEFAULT_BODY.format(cls="TestSkin")) - bump_mtime(skin_dir / "skin.py") - - skins = skin_runtime.discover_skins(tmp_path) - assert counter["count"] == count_after_first # cache still served - assert "t04-pyedit" in skins - - def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path, - monkeypatch): - make_skin(tmp_path, "t05-force") - counter = counting_read_manifest(monkeypatch) - skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert counter["count"] > count_after_first - - def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path): - make_skin(tmp_path, "t06-copy") - result = skin_runtime.discover_skins(tmp_path) - - # Mutating the returned mapping does not poison the cache... - del result["t06-copy"] - again = skin_runtime.discover_skins(tmp_path) # cache hit - assert "t06-copy" in again - - # ...but the inner manifest dicts ARE shared with the cache (pin). - again["t06-copy"]["name"] = "mutated-inner" - third = skin_runtime.discover_skins(tmp_path) # cache hit - assert third["t06-copy"]["name"] == "mutated-inner" - - def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path): - missing = tmp_path / "not-yet" - assert skin_runtime.discover_skins(missing) == {} - assert str(missing) not in skin_runtime._discovery_cache - - # Creating the directory later is picked up without force_refresh. - make_skin(missing, "t07-late") - skins = skin_runtime.discover_skins(missing) - assert "t07-late" in skins - - def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path): - make_skin(tmp_path, ".hidden-skin") - make_skin(tmp_path, "_private-skin") - (tmp_path / "stray-file").write_text("not a directory") - make_skin(tmp_path, "t08-good") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert set(skins) == {"t08-good"} - - def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path): - make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "t09-manifest-id" in skins - assert "t09-dirname" not in skins - assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname") - - def test_falsy_required_field_drops_skin(self, tmp_path): - make_skin(tmp_path, "t10-empty-class", class_name="") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert skins == {} - - -# --------------------------------------------------------------------------- -# B. Module namespacing (_load_skin_module via load_skin) -# --------------------------------------------------------------------------- - -BODY_WITH_HELPERS = ( - "import helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " pass\n" -) - - -class TestModuleNamespacing: - def test_namespaced_sys_modules_keys(self, tmp_path): - make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 11\n"}) - skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path) - assert skin is not None - assert "_skin_t11-ns_skin" in sys.modules - assert "_skin_t11-ns_helpers" in sys.modules - - def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch): - sentinel = object() - monkeypatch.setitem(sys.modules, "helpers", sentinel) - make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 'a'\n"}) - skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path) - assert skin is not None - assert sys.modules["helpers"] is sentinel - - def test_absent_bare_name_stays_absent(self, tmp_path): - saved = sys.modules.pop("helpers", None) - try: - assert "helpers" not in sys.modules - make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 'b'\n"}) - skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path) - assert skin is not None - assert "helpers" not in sys.modules - finally: - if saved is not None: - sys.modules["helpers"] = saved - - def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path): - real_json = sys.modules["json"] - make_skin(tmp_path, "t12c-json", - extra_files={"json.py": "SKIN_LOCAL = True\n"}) - skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path) - assert skin is not None - assert sys.modules["json"] is real_json - assert not hasattr(sys.modules["json"], "SKIN_LOCAL") - assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works - # The skin's copy lives only under its namespaced alias. - assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True - - def test_entry_module_executed_once_across_loads(self, tmp_path, - monkeypatch): - executions = [] - monkeypatch.setattr(builtins, "_t13_skin_executions", executions, - raising=False) - body = ( - "import builtins\n" - "builtins._t13_skin_executions.append(1)\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " pass\n" - ) - make_skin(tmp_path, "t13-cached", body=body) - for _ in range(3): - skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path) - assert skin is not None - assert len(executions) == 1 # module executed exactly once - - def test_sibling_import_failure_returns_none_and_restores_bare( - self, tmp_path, monkeypatch): - sentinel = object() - monkeypatch.setitem(sys.modules, "helpers", sentinel) - make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"}) - assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None - assert sys.modules["helpers"] is sentinel - - def test_missing_entry_point_file(self, tmp_path): - make_skin(tmp_path, "t15-noentry", write_entry=False) - assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None - - def test_custom_entry_point(self, tmp_path): - make_skin(tmp_path, "t16-custom", entry_point="render.py") - skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - assert "_skin_t16-custom_render" in sys.modules - assert "_skin_t16-custom_skin" not in sys.modules - - def test_class_name_pointing_at_unrelated_class(self, tmp_path): - body = "class NotASkin:\n pass\n" - make_skin(tmp_path, "t17a-wrongclass", body=body, - class_name="NotASkin") - assert skin_runtime.load_skin("t17a-wrongclass", - skins_dir=tmp_path) is None - - def test_class_name_pointing_at_instance(self, tmp_path): - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class MySkin(ScoreboardSkin):\n" - " pass\n" - "obj = MySkin({}, {})\n" - ) - make_skin(tmp_path, "t17b-instance", body=body, class_name="obj") - assert skin_runtime.load_skin("t17b-instance", - skins_dir=tmp_path) is None - - def test_constructor_raising_returns_none(self, tmp_path): - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def __init__(self, manifest, options):\n" - " raise ValueError('ctor boom')\n" - ) - make_skin(tmp_path, "t18-ctor", body=body) - assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None - - -# --------------------------------------------------------------------------- -# C. API gate + targeting -# --------------------------------------------------------------------------- - -class TestApiGateAndTargeting: - def test_same_major_higher_minor_loads(self, tmp_path): - make_skin(tmp_path, "t19-minor", api_version="1.9.0") - skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - - def test_malformed_api_version_refused(self, tmp_path): - make_skin(tmp_path, "t20-malformed", api_version="abc") - assert skin_runtime.load_skin("t20-malformed", - skins_dir=tmp_path) is None - - @pytest.mark.parametrize("manifest,sport,sport_key,expected", [ - # No targets key at all -> matches everything - ({"id": "x"}, "baseball", "mlb", True), - ({"id": "x"}, None, None, True), - # Empty targets dict -> matches everything - ({"id": "x", "targets": {}}, "hockey", None, True), - # sports family match - ({"id": "x", "targets": {"sports": ["baseball"]}}, - "baseball", None, True), - # sport_keys exact match - ({"id": "x", "targets": {"sport_keys": ["milb"]}}, - None, "milb", True), - # OR semantics: sport_keys matches even though sports excludes it - ({"id": "x", "targets": {"sports": ["hockey"], - "sport_keys": ["milb"]}}, - "baseball", "milb", True), - # Neither matches - ({"id": "x", "targets": {"sports": ["hockey"]}}, - "baseball", None, False), - ({"id": "x", "targets": {"sports": ["hockey"], - "sport_keys": ["nhl"]}}, - "baseball", "milb", False), - ]) - def test_skin_matches_target(self, manifest, sport, sport_key, expected): - assert skin_runtime.skin_matches_target( - manifest, sport, sport_key) is expected diff --git a/test/test_skin_system.py b/test/test_skin_system.py deleted file mode 100644 index 1a7138c2..00000000 --- a/test/test_skin_system.py +++ /dev/null @@ -1,571 +0,0 @@ -"""Tests for the skin system: discovery, version gating, fallback -semantics, module isolation, and the view-model contract.""" - -import json -import logging -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image, ImageFont - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so the fallback-semantics tests can import SportsCore off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.skin_system import skin_runtime -from src.skin_system.skin_base import ( - SKIN_API_VERSION, - ScoreboardSkin, - SkinContext, -) - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" - -# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming -# or removing any of these is a breaking change to every published skin: -# it requires a VIEW_MODEL_VERSION major bump and a compat shim. -GUARANTEED_KEYS = [ - "id", "game_time", "game_date", "start_time_utc", "status_text", - "is_live", "is_final", "is_upcoming", "is_halftime", - "home_abbr", "home_id", "home_score", "home_logo_path", "home_record", - "away_abbr", "away_id", "away_score", "away_logo_path", "away_record", -] - - -def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION, - body: str = None, extra_files: dict = None, - class_name: str = "TestSkin") -> Path: - skin_dir = skins_dir / skin_id - skin_dir.mkdir(parents=True) - manifest = { - "id": skin_id, "name": skin_id, "version": "1.0.0", - "skin_api_version": api_version, "class_name": class_name, - "targets": {"sports": ["baseball"]}, - } - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - if body is None: - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - f"class {class_name}(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n" - " return True\n" - ) - (skin_dir / "skin.py").write_text(body) - for name, content in (extra_files or {}).items(): - (skin_dir / name).write_text(content) - return skin_dir - - -class TestDiscovery: - def test_discovers_valid_skin(self, tmp_path): - write_skin(tmp_path, "my-skin") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "my-skin" in skins - assert skins["my-skin"]["_skin_dir"].endswith("my-skin") - - def test_skips_manifest_missing_required_fields(self, tmp_path): - skin_dir = tmp_path / "broken" - skin_dir.mkdir() - (skin_dir / "skin.json").write_text(json.dumps({"id": "broken"})) - assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {} - - def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path): - (tmp_path / "not-a-skin").mkdir() - bad = tmp_path / "bad-json" - bad.mkdir() - (bad / "skin.json").write_text("{nope") - write_skin(tmp_path, "good-skin") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert list(skins) == ["good-skin"] - - def test_missing_directory_is_empty(self, tmp_path): - assert skin_runtime.discover_skins(tmp_path / "nope") == {} - - def test_example_skin_in_repo_is_discoverable(self): - skins = skin_runtime.discover_skins(force_refresh=True) - assert "example-classic-baseball" in skins - - -class TestLoadSkin: - def test_loads_and_instantiates(self, tmp_path): - write_skin(tmp_path, "my-skin") - skin = skin_runtime.load_skin("my-skin", sport="baseball", - skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - - def test_unknown_skin_returns_none(self, tmp_path): - assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None - - def test_api_major_mismatch_is_refused(self, tmp_path): - write_skin(tmp_path, "old-skin", api_version="99.0.0") - assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None - - def test_target_mismatch_still_loads(self, tmp_path): - write_skin(tmp_path, "my-skin") # targets baseball - skin = skin_runtime.load_skin("my-skin", sport="hockey", - skins_dir=tmp_path) - assert skin is not None # soft warning, not a hard block - - def test_import_error_returns_none(self, tmp_path): - write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n") - assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None - - def test_wrong_class_returns_none(self, tmp_path): - write_skin(tmp_path, "classless", body="x = 1\n") - assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None - - def test_options_are_passed_through(self, tmp_path): - write_skin(tmp_path, "my-skin") - skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path, - options={"accent": [1, 2, 3]}) - assert skin.options == {"accent": [1, 2, 3]} - - def test_sibling_modules_are_isolated_between_skins(self, tmp_path): - helper = "VALUE = {!r}\n" - body = ( - "import helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " ctx.logger.info(helpers.VALUE)\n" - " self.helper_value = helpers.VALUE\n" - " return False\n" - ) - write_skin(tmp_path, "skin-a", body=body, - extra_files={"helpers.py": helper.format("A")}) - write_skin(tmp_path, "skin-b", body=body, - extra_files={"helpers.py": helper.format("B")}) - skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path) - skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path) - ctx = _make_context() - skin_a.render_live(ctx, {}) - skin_b.render_live(ctx, {}) - assert skin_a.helper_value == "A" - assert skin_b.helper_value == "B" - - def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path): - """The live/recent/upcoming hosts each load the same skin — the - 2nd and 3rd loads must still resolve sibling modules (regression: - cached siblings used to be skipped without rebinding).""" - body = ( - "import reload_helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " self.helper_value = reload_helpers.VALUE\n" - " return False\n" - ) - write_skin(tmp_path, "reload-skin", body=body, - extra_files={"reload_helpers.py": "VALUE = 'R'\n"}) - ctx = _make_context() - for _ in range(3): - skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path) - assert skin is not None - skin.render_live(ctx, {}) - assert skin.helper_value == "R" - - -def _make_host(fonts=None): - host = MagicMock() - host.sport = "baseball" - host.sport_key = "mlb" - host.skin_options = {"accent": True} - host.fonts = fonts or {"time": ImageFont.load_default()} - host.logger = logging.getLogger("test_skin_system") - host.display_manager.width = 128 - host.display_manager.height = 32 - return host - - -def _make_context(width=128, height=32): - host = _make_host() - return skin_runtime.build_context(host, {}, size=(width, height)) - - -class TestBuildContext: - def test_context_shape(self): - host = _make_host() - game = {"home_abbr": "LAD", "away_abbr": "SF"} - ctx = skin_runtime.build_context(host, game) - assert (ctx.width, ctx.height) == (128, 32) - assert ctx.canvas.size == (128, 32) - assert ctx.sport == "baseball" - assert ctx.options == {"accent": True} - assert ctx.layout.bounds.w == 128 - - def test_explicit_size_overrides_display(self): - ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64)) - assert ctx.canvas.size == (64, 64) - - def test_load_logo_binds_game_and_survives_failure(self): - host = _make_host() - host._load_and_resize_logo.side_effect = RuntimeError("disk gone") - ctx = skin_runtime.build_context( - host, {"home_id": "1", "home_abbr": "LAD", - "home_logo_path": "x.png", "home_logo_url": None}) - assert ctx.load_logo("home") is None # exception swallowed - assert ctx.load_logo("elsewhere") is None # bad side rejected - - def test_draw_helpers_draw_on_canvas(self): - ctx = _make_context() - ctx.draw_text("HI", 2, 2, font=ImageFont.load_default()) - fit = ctx.layout.fit_text("42", ctx.layout.bounds) - ctx.draw_fit(fit, ctx.layout.bounds) - logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255)) - ctx.draw_image(logo, ctx.layout.bounds.left_col(20)) - ctx.draw_image(None, ctx.layout.bounds) # None must no-op - assert ctx.canvas.convert("L").getbbox() is not None - - -class _FallbackProbe: - """Bare-bones SportsCore stand-in that exercises the real _render_game.""" - - def __init__(self, skin): - from src.base_classes.sports import SportsCore - self._cls = SportsCore - self.SKIN_MODE = "live" - self.logger = logging.getLogger("test_skin_system") - self.sport = "baseball" - self.sport_key = "mlb" - self.skin_options = {} - self.fonts = {"time": ImageFont.load_default()} - self._skin = skin - self._skin_load_attempted = True - self._skin_failures = 0 - self._skin_slow_renders = 0 - self._skin_config = "test-skin" - self.display_manager = MagicMock() - self.display_manager.width = 128 - self.display_manager.height = 32 - self.display_manager.image = Image.new("RGB", (128, 32)) - self.builtin_calls = 0 - - def _resolve_skin_id(self): - return "test-skin" - - def _draw_scorebug_layout(self, game, force_clear=False): - self.builtin_calls += 1 - - def _render_game(self, game, force_clear=False): - from src.base_classes.sports import SportsCore - SportsCore._render_game(self, game, force_clear) - - def _get_skin(self): - return self._skin - - -class TestRenderGameFallback: - def test_skin_handles_render(self): - class GoodSkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0)) - return True - - probe = _FallbackProbe(GoodSkin({}, {})) - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 0 - probe.display_manager.update_display.assert_called_once() - assert probe.display_manager.image.convert("L").getbbox() is not None - - def test_skin_declining_falls_back(self): - probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - - def test_no_skin_falls_back(self): - probe = _FallbackProbe(None) - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - - def test_three_strikes_disables_skin(self): - class BrokenSkin(ScoreboardSkin): - calls = 0 - - def render_live(self, ctx, game): - BrokenSkin.calls += 1 - raise ValueError("kaboom") - - probe = _FallbackProbe(BrokenSkin({}, {})) - for i in range(5): - probe._render_game({"status_text": "Q1"}) - # every render fell back to the built-in layout... - assert probe.builtin_calls == 5 - # ...and the skin stopped being called after the 3rd failure - assert BrokenSkin.calls == 3 - assert probe._skin_failures == 3 - - def test_skin_cannot_mutate_callers_game_dict(self): - class MutatingSkin(ScoreboardSkin): - def render_live(self, ctx, game): - game.clear() - game["hacked"] = True - return True - - probe = _FallbackProbe(MutatingSkin({}, {})) - game = {"status_text": "Q1", "home_score": "3"} - probe._render_game(game) - assert game == {"status_text": "Q1", "home_score": "3"} - - -class TestSkinModeResolution: - def _core(self, skin_config, mode="live"): - from src.base_classes.sports import SportsCore - probe = _FallbackProbe(None) - probe.SKIN_MODE = mode - probe._skin_config = skin_config - return SportsCore._resolve_skin_id(probe) - - def test_plain_id_applies_to_all_modes(self): - assert self._core("retro", "live") == "retro" - assert self._core("retro", "recent") == "retro" - - def test_per_mode_mapping(self): - cfg = {"live": "retro", "recent": "built-in"} - assert self._core(cfg, "live") == "retro" - assert self._core(cfg, "recent") is None - assert self._core(cfg, "upcoming") is None - - def test_builtin_and_empty_mean_none(self): - assert self._core("built-in") is None - assert self._core("") is None - assert self._core(None) is None - - -class TestViewModelContract: - @pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"]) - @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) - def test_fixtures_carry_all_guaranteed_keys(self, sport, mode): - with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f: - game = json.load(f) - missing = [k for k in GUARANTEED_KEYS if k not in game] - assert not missing, f"{sport}_{mode} fixture missing {missing}" - - def test_extractor_produces_guaranteed_keys(self): - """The real extractor's output must be a superset of the documented - contract — this is the test that catches accidental renames.""" - import pytz - from src.base_classes.sports import SportsCore - - event = { - "id": "401570001", - "date": "2026-07-16T23:05:00Z", - "competitions": [{ - "status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in", - "shortDetail": "Bot 7th"}}, - "competitors": [ - {"homeAway": "home", "id": "19", - "team": {"abbreviation": "LAD"}, "score": "5", - "records": [{"summary": "58-33"}]}, - {"homeAway": "away", "id": "26", - "team": {"abbreviation": "SF"}, "score": "3", - "records": [{"summary": "49-42"}]}, - ], - }], - } - probe = MagicMock() - probe.logger = logging.getLogger("test_skin_system") - probe.favorite_teams = [] - probe.config = {} - probe.logo_dir = Path("assets/logos") - probe._get_timezone.return_value = pytz.utc - probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th" - - details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event) - assert details is not None - missing = [k for k in GUARANTEED_KEYS if k not in details] - assert not missing, ( - f"_extract_game_details_common no longer emits {missing}. " - "These keys are part of the frozen skin view-model contract " - "(VIEW_MODEL_VERSION) — renaming or removing them breaks every " - "published skin. Add a compat shim or bump the major version.") - - -class TestPluginMatching: - def test_matches_by_sport_token_and_sport_key(self, tmp_path): - write_skin(tmp_path, "bb-skin") # targets sports=["baseball"] - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) - assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins) - - def test_matches_by_explicit_plugin_list(self, tmp_path): - skin_dir = write_skin(tmp_path, "exact-skin") - manifest = json.loads((skin_dir / "skin.json").read_text()) - manifest["targets"] = {"plugins": ["my-custom-plugin"]} - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins) - assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) - - -class TestSchemaInjection: - def _manager(self): - from src.plugin_system.schema_manager import SchemaManager - return SchemaManager() - - def test_injects_enum_with_installed_and_configured_skins(self): - sm = self._manager() - schema = {"type": "object", "properties": {}} - out = sm.inject_skin_selector(schema, "baseball-scoreboard", - current_value="gone-skin") - enum = out["properties"]["skin"]["enum"] - assert enum[0] == "built-in" - assert "example-classic-baseball" in enum - # an uninstalled-but-configured skin must stay selectable so the - # saved config never becomes invalid in the UI - assert "gone-skin" in enum - assert "skin" not in schema["properties"] # source schema untouched - - def test_no_matching_skins_leaves_schema_alone(self): - sm = self._manager() - schema = {"type": "object", "properties": {}} - out = sm.inject_skin_selector(schema, "totally-unrelated-plugin") - assert "skin" not in out.get("properties", {}) - - def test_validation_accepts_skin_keys_without_enum(self): - sm = self._manager() - schema = {"type": "object", "properties": {"foo": {"type": "string"}}} - ok, errors = sm.validate_config_against_schema( - {"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}}, - schema, "baseball-scoreboard") - assert ok, errors - ok, errors = sm.validate_config_against_schema( - {"skin": {"live": "a", "recent": "built-in"}}, schema, "p") - assert ok, errors - - -class TestExampleSkin: - @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) - @pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)]) - def test_renders_all_modes_and_sizes(self, mode, size): - skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball") - assert skin is not None - host = _make_host() - host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255)) - with open(FIXTURES_DIR / f"baseball_{mode}.json") as f: - game = json.load(f) - ctx = skin_runtime.build_context(host, game, size=size) - assert getattr(skin, f"render_{mode}")(ctx, game) is True - assert ctx.canvas.convert("L").getbbox() is not None - - -class TestRenderSkinCard: - """render_skin_card (vegas cards) shares _render_game's 3-strike counter. - - Both paths reset the counter on success — transient failures must not - accumulate across a session and disable a working skin. - """ - - def _probe(self, skin): - from src.base_classes.sports import SportsCore - probe = _FallbackProbe(skin) - probe.render_skin_card = ( - lambda game, size: SportsCore.render_skin_card(probe, game, size)) - return probe - - def test_vegas_card_returned_when_skin_provides_one(self): - card_img = Image.new("RGB", (96, 32), (0, 0, 255)) - - class CardSkin(ScoreboardSkin): - def render_vegas_card(self, ctx, game): - return card_img - - probe = self._probe(CardSkin({}, {})) - assert probe.render_skin_card({}, (96, 32)) is card_img - - def test_vegas_card_none_falls_through_to_mode_renderer(self): - class ModeOnlySkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) - return True - - probe = self._probe(ModeOnlySkin({}, {})) - card = probe.render_skin_card({}, (96, 32)) - assert card is not None - assert card.size == (96, 32) - assert card.convert("L").getbbox() is not None - - def test_skin_declining_returns_none(self): - probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False - assert probe.render_skin_card({}, (96, 32)) is None - assert probe._skin_failures == 0 # declining is not a failure - - def test_no_skin_returns_none(self): - probe = self._probe(None) - assert probe.render_skin_card({}, (96, 32)) is None - - def test_card_failures_count_toward_shared_disable(self): - class BrokenCardSkin(ScoreboardSkin): - calls = 0 - - def render_vegas_card(self, ctx, game): - BrokenCardSkin.calls += 1 - raise ValueError("kaboom") - - probe = self._probe(BrokenCardSkin({}, {})) - for _ in range(5): - assert probe.render_skin_card({}, (96, 32)) is None - # Skin stopped being consulted after the 3rd failure... - assert BrokenCardSkin.calls == 3 - assert probe._skin_failures == 3 - # ...and the shared counter also disables _render_game's skin path. - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - assert BrokenCardSkin.calls == 3 # not consulted again - - def test_card_success_resets_strikes(self): - """A successful card render clears accumulated strikes (mirroring - _render_game) — 2 failures + a success + 1 failure leaves the skin - enabled with a single strike, instead of disabling it.""" - card_img = Image.new("RGB", (96, 32), (0, 0, 255)) - - class FlakyCardSkin(ScoreboardSkin): - fail = True - - def render_vegas_card(self, ctx, game): - if FlakyCardSkin.fail: - raise ValueError("kaboom") - return card_img - - probe = self._probe(FlakyCardSkin({}, {})) - FlakyCardSkin.fail = True - probe.render_skin_card({}, (96, 32)) - probe.render_skin_card({}, (96, 32)) - assert probe._skin_failures == 2 - - FlakyCardSkin.fail = False - assert probe.render_skin_card({}, (96, 32)) is card_img - assert probe._skin_failures == 0 # success cleared the strikes - - FlakyCardSkin.fail = True - probe.render_skin_card({}, (96, 32)) - assert probe._skin_failures == 1 # counting from the reset state - FlakyCardSkin.fail = False - assert probe.render_skin_card({}, (96, 32)) is card_img # still enabled - - def test_card_success_via_mode_renderer_also_resets_strikes(self): - """The fallthrough path (render_vegas_card None -> mode renderer - True) resets the counter as well.""" - class ModeOnlySkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) - return True - - probe = self._probe(ModeOnlySkin({}, {})) - probe._skin_failures = 2 - assert probe.render_skin_card({}, (96, 32)) is not None - assert probe._skin_failures == 0 - - def test_render_game_success_also_resets_strikes(self): - """Same reset contract on the display path, for symmetry.""" - class GoodSkin(ScoreboardSkin): - def render_live(self, ctx, game): - return True - - probe = _FallbackProbe(GoodSkin({}, {})) - probe._skin_failures = 2 - probe._render_game({"status_text": "Q1"}) - assert probe._skin_failures == 0 diff --git a/test/test_sports_base_characterization.py b/test/test_sports_base_characterization.py deleted file mode 100644 index d81919bb..00000000 --- a/test/test_sports_base_characterization.py +++ /dev/null @@ -1,660 +0,0 @@ -"""Characterization tests for src/base_classes/sports.py. - -These tests PIN the current behavior of SportsCore / SportsUpcoming / -SportsRecent / SportsLive ahead of the sports-unification merge (features -from nine drifted plugin copies are about to be folded in). They assert -what the code DOES today, not what it should do — a few pinned behaviors -look like bugs and are flagged inline with "PINNED AS-IS". - -Coverage: -- `_extract_game_details_common` + the four sport extractors - (football/hockey/baseball/basketball) against realistic ESPN scoreboard - events (adapted from the ledmatrix-plugins monorepo test fixtures). - The output must remain a superset of the frozen skin view-model - contract (GUARANTEED_KEYS, imported from test_skin_system). -- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive - subclasses: population, favorite-team filtering, empty/failed-fetch - tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache - and every instance's requests session raises ConnectionError. -- Rendering smoke: one `display()` per mode class at 128x32 draws - non-zero ink onto a real PIL image. -- Guard rails: the skin-system seam methods on SportsCore must survive - the merge. -""" - -import logging -import sys -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -import pytz -import requests -from freezegun import freeze_time -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.baseball import Baseball -from src.base_classes.basketball import Basketball -from src.base_classes.football import Football -from src.base_classes.hockey import Hockey, HockeyLive -from src.base_classes.sports import ( - SportsCore, - SportsLive, - SportsRecent, - SportsUpcoming, -) - -# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it. -from test.test_skin_system import GUARANTEED_KEYS - -SPORT_CLASSES = [Football, Hockey, Baseball, Basketball] -SPORT_IDS = ["football", "hockey", "baseball", "basketball"] - -# All update()-flow tests run at this frozen instant so the 21-day -# SportsRecent window and time.time() interval gates are deterministic. -FROZEN_NOW = "2026-01-20 12:00:00" - - -# --------------------------------------------------------------------------- -# ESPN scoreboard event builders (shape adapted from the monorepo fixtures, -# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json: -# team-shaped competitors with status/score/records). -# --------------------------------------------------------------------------- - -def _competitor(abbr, team_id, score, home_away, record="30-10-5"): - return { - "homeAway": home_away, - "id": team_id, - "score": score, - "team": { - "id": team_id, - "abbreviation": abbr, - "name": abbr.title(), - "displayName": abbr.title(), - "logo": None, - }, - "records": [{"summary": record}], - # The hockey extractor reads competitor["statistics"] for shot counts; - # it now defaults to an empty list when the key is absent rather than - # dropping the whole event (see - # test_hockey_event_without_statistics_still_extracts). - "statistics": [], - } - - -def make_event(event_id, state, date, home=("TB", "20", "3"), - away=("DAL", "9", "2"), period=2, clock="12:45", - name=None, short_detail=None, situation=None, - home_record="30-10-5", away_record="25-14-6"): - """Build a realistic ESPN scoreboard event in the given state - ('in' / 'post' / 'pre').""" - defaults = { - "in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"), - "post": ("STATUS_FINAL", "Final"), - "pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"), - } - default_name, default_detail = defaults[state] - status = { - "clock": 0.0, - "displayClock": clock, - "period": period, - "type": { - "id": "2", - "name": name or default_name, - "state": state, - "completed": state == "post", - "description": short_detail or default_detail, - "detail": short_detail or default_detail, - "shortDetail": short_detail or default_detail, - }, - } - competition = { - "id": event_id, - "date": date, - "status": status, - "competitors": [ - _competitor(home[0], home[1], home[2], "home", home_record), - _competitor(away[0], away[1], away[2], "away", away_record), - ], - } - if situation is not None: - competition["situation"] = situation - return { - "id": event_id, - "date": date, - "name": f"{away[0]} at {home[0]}", - "shortName": f"{away[0]} @ {home[0]}", - "competitions": [competition], - # Real ESPN payloads duplicate status at the event top level; the - # baseball extractor reads it there for live innings. - "status": status, - } - - -def make_probe(favorites=None): - """Bare-bones SportsCore stand-in for exercising the real extractors - unbound (same pattern as TestViewModelContract in test_skin_system).""" - probe = MagicMock() - probe.logger = logging.getLogger("test_sports_base_characterization") - probe.favorite_teams = list(favorites or []) - probe.config = {} - probe.logo_dir = Path("assets/logos") - probe._get_timezone.return_value = pytz.utc - probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th" - # The sport extractors call self._extract_game_details_common — route - # it to the real implementation instead of a MagicMock. - probe._extract_game_details_common = ( - lambda event: SportsCore._extract_game_details_common(probe, event)) - return probe - - -def extract(sport_cls, event, favorites=None): - return sport_cls._extract_game_details(make_probe(favorites), event) - - -# --------------------------------------------------------------------------- -# 1. _extract_game_details_common contract, per wired sport -# --------------------------------------------------------------------------- - -class TestExtractGameDetailsContract: - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_live_event_guaranteed_keys_and_values(self, sport_cls): - event = make_event("401", "in", "2026-01-15T18:30:00Z") - details = extract(sport_cls, event) - assert details is not None - missing = [k for k in GUARANTEED_KEYS if k not in details] - assert not missing, ( - f"{sport_cls.__name__} extractor no longer emits {missing} — " - "these keys are the frozen skin view-model contract.") - assert details["id"] == "401" - assert details["home_abbr"] == "TB" - assert details["away_abbr"] == "DAL" - assert details["home_id"] == "20" - assert details["away_id"] == "9" - assert details["home_score"] == "3" - assert details["away_score"] == "2" - assert details["home_record"] == "30-10-5" - assert details["away_record"] == "25-14-6" - assert details["is_live"] is True - assert details["is_final"] is False - assert details["is_upcoming"] is False - assert details["status_text"] == "P2 12:45" - assert details["start_time_utc"] == datetime( - 2026, 1, 15, 18, 30, tzinfo=timezone.utc) - # Sport-specific formatting of the same event: - if sport_cls in (Football, Basketball): - assert details["period_text"] == "Q2" - assert details["clock"] == "12:45" - elif sport_cls is Hockey: - assert details["period_text"] == "P2" - assert details["clock"] == "12:45" - else: # Baseball keys inning/status instead of period_text - assert details["inning"] == 2 - assert details["status_state"] == "in" - - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_final_event_classification(self, sport_cls): - event = make_event("402", "post", "2026-01-14T00:00:00Z", - home=("BOS", "1", "4"), away=("TOR", "21", "2"), - period=3, clock="0:00") - details = extract(sport_cls, event) - assert details is not None - assert details["is_final"] is True - assert details["is_live"] is False - assert details["is_upcoming"] is False - assert details["home_score"] == "4" - assert details["away_score"] == "2" - if sport_cls in (Football, Hockey, Basketball): - assert details["period_text"] == "Final" - - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_upcoming_event_classification(self, sport_cls): - event = make_event("403", "pre", "2026-01-15T18:30:00Z", - home=("NYR", "13", "0"), away=("PIT", "16", "0"), - period=0, clock="0:00") - details = extract(sport_cls, event) - assert details is not None - assert details["is_upcoming"] is True - assert details["is_live"] is False - assert details["is_final"] is False - # Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM, - # date rendered through display_manager.format_date_with_ordinal. - assert details["game_time"] == "6:30PM" - assert details["game_date"] == "Jan 15th" - - def test_halftime_state_flags(self): - # is_halftime keys off name STATUS_HALFTIME (or state "halftime") - # while state "in" still counts as live. - event = make_event("404", "in", "2026-01-15T18:30:00Z", - name="STATUS_HALFTIME", short_detail="Halftime") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["is_live"] is True - assert details["is_halftime"] is True - - def test_state_name_conflict_is_both_final_and_upcoming(self): - # PINNED AS-IS (looks like a bug): is_upcoming also matches on - # status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so - # an event with state="post" but name="Scheduled" reports BOTH - # is_final and is_upcoming True. - event = make_event("405", "post", "2026-01-14T00:00:00Z", - name="Scheduled") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["is_final"] is True - assert details["is_upcoming"] is True - - def test_zero_zero_record_blanked(self): - event = make_event("406", "pre", "2026-01-15T18:30:00Z", - home_record="0-0", away_record="0-0-0") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["home_record"] == "" - assert details["away_record"] == "" - - def test_missing_abbreviation_uses_name_prefix(self): - event = make_event("407", "pre", "2026-01-15T18:30:00Z") - for comp in event["competitions"][0]["competitors"]: - del comp["team"]["abbreviation"] - comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \ - else "Penguins" - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["home_abbr"] == "Sha" - assert details["away_abbr"] == "Pen" - - def test_empty_or_malformed_event_returns_none_tuple(self): - probe = make_probe() - assert SportsCore._extract_game_details_common(probe, {}) == \ - (None, None, None, None, None) - assert SportsCore._extract_game_details_common(probe, None) == \ - (None, None, None, None, None) - # Malformed event (no competitions) is swallowed, not raised. - assert SportsCore._extract_game_details_common( - probe, {"id": "999"}) == (None, None, None, None, None) - - def test_football_live_situation_fields(self): - event = make_event( - "408", "in", "2026-01-15T18:30:00Z", - situation={ - "shortDownDistanceText": "3rd & 4", - "downDistanceText": "3rd & 4 at TB 30", - "isRedZone": False, - "possession": "20", - "homeTimeouts": 2, - "awayTimeouts": 3, - }) - details = extract(Football, event) - assert details["down_distance_text"] == "3rd & 4" - assert details["down_distance_text_long"] == "3rd & 4 at TB 30" - assert details["possession"] == "20" - assert details["possession_indicator"] == "home" # matches home id - assert details["home_timeouts"] == 2 - assert details["away_timeouts"] == 3 - - def test_hockey_live_power_play_and_default_shots(self): - event = make_event("409", "in", "2026-01-15T18:30:00Z", - situation={"isPowerPlay": True, "penalties": ""}) - details = extract(Hockey, event) - assert details["power_play"] is True - # Empty statistics arrays -> save-percentage math yields 0 shots. - assert details["home_shots"] == 0 - assert details["away_shots"] == 0 - - def test_hockey_event_without_statistics_still_extracts(self): - # FIXED (was pinned as returning None): the hockey extractor used to - # iterate competitor["statistics"] unguarded, so a competitor without - # the key raised KeyError internally and the WHOLE event was dropped - # despite valid scores and status. It now defaults to an empty list, - # matching the behaviour already shipped in the hockey plugin, so the - # event survives with zeroed shot counts -- the same values - # test_hockey_live_power_play_and_default_shots already expects for an - # EMPTY statistics array. - event = make_event("410", "in", "2026-01-15T18:30:00Z") - for comp in event["competitions"][0]["competitors"]: - del comp["statistics"] - details = extract(Hockey, event) - assert details is not None - assert details["home_abbr"] == "TB" - assert details["away_abbr"] == "DAL" - assert details["home_score"] == "3" - assert details["home_shots"] == 0 - assert details["away_shots"] == 0 - - def test_baseball_live_inning_and_count(self): - event = make_event( - "411", "in", "2026-07-16T23:05:00Z", - home=("LAD", "19", "5"), away=("SF", "26", "3"), - period=7, short_detail="Bot 7th", - situation={ - "count": {"balls": 2, "strikes": 1}, - "outs": 2, - "onFirst": True, - "onSecond": False, - "onThird": True, - }) - details = extract(Baseball, event) - assert details["inning"] == 7 # from top-level status period - assert details["inning_half"] == "bottom" - assert details["balls"] == 2 - assert details["strikes"] == 1 - assert details["outs"] == 2 - assert details["bases_occupied"] == [True, False, True] - assert details["status"] == "status_in_progress" - assert details["series_summary"] == "" - - def test_baseball_live_without_top_level_status_still_extracts(self): - # FIXED (was pinned as returning None): the baseball extractor read - # game_event["status"] -- the event TOP-LEVEL status -- for the - # inning, so an otherwise-valid live event lacking that duplicate key - # was dropped entirely. Real ESPN events carry status in both places, - # but MiLB events (synthesized from the MLB Stats API into an - # ESPN-like shape) populate only the competition-level one. It now - # reads the competition-level `status` that - # _extract_game_details_common has already validated, so it can never - # be missing at that point. - event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7) - del event["status"] - details = extract(Baseball, event) - assert details is not None - assert details["inning"] == 7 - - def test_baseball_live_without_top_level_status_extracts_for_favorites(self): - # The favourite-team branch logs the status payload for diagnostics and - # read the same event top-level key the test above proves can be - # absent. So the identical MiLB event that extracts fine for a - # non-favourite raised KeyError and was dropped once the team WAS a - # favourite -- the worst shape for the bug, since it only hit the games - # the user cared most about, and only on the diagnostic path that was - # supposed to help debug them. - event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7) - del event["status"] - details = extract(Baseball, event, favorites=["TB"]) - assert details is not None - assert details["inning"] == 7 - - -# --------------------------------------------------------------------------- -# 2. update() flow on concrete subclasses (offline, cache-fed) -# --------------------------------------------------------------------------- - -class _UpcomingHarness(Hockey, SportsUpcoming): - """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.""" - - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -class _RecentHarness(Hockey, SportsRecent): - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -class _LiveHarness(HockeyLive): - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -def make_schedule(): - """A mixed schedule around the frozen 'now' of 2026-01-20.""" - return {"events": [ - # Final 6 days ago — inside the recent 21-day window. - make_event("9001", "post", "2026-01-14T00:00:00Z", - home=("BOS", "1", "4"), away=("TOR", "21", "2"), - period=3, clock="0:00"), - # Live game. - make_event("9002", "in", "2026-01-15T00:30:00Z", - home=("TB", "20", "3"), away=("DAL", "9", "2")), - # Two scheduled games. - make_event("9003", "pre", "2026-01-16T00:00:00Z", - home=("NYR", "13", "0"), away=("PIT", "16", "0"), - period=0), - make_event("9004", "pre", "2026-01-17T00:00:00Z", - home=("BOS", "1", "0"), away=("MTL", "10", "0"), - period=0), - # Final from November — outside the recent 21-day window. - make_event("9005", "post", "2025-11-01T00:00:00Z", - home=("SEA", "124292", "1"), away=("VAN", "22", "5"), - period=3, clock="0:00"), - ]} - - -@pytest.fixture -def build_manager(monkeypatch, tmp_path): - """Factory for concrete sports managers: mocked display/cache managers, - logo dir redirected to tmp, background service stubbed, and the - requests session rigged to prove nothing hits the network.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - # Rig requests.Session BEFORE any manager is built. Construction creates - # both SportsCore.session and the ESPNDataSource.session; replacing only - # manager.session after the fact (below) leaves data_source.session real, - # so an accidental fetch during or right after construction could reach - # the network. Patching the class makes every session created here raise. - def _offline_get(*args, **kwargs): - raise requests.exceptions.ConnectionError( - "characterization tests are offline") - - monkeypatch.setattr(requests.Session, "get", _offline_get) - - def build(cls, schedule, **mode_cfg): - config = { - "timezone": "UTC", - "display": {}, - "nhl_scoreboard": {"enabled": True, **mode_cfg}, - } - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - display_manager.format_date_with_ordinal.side_effect = ( - lambda dt: dt.strftime("%b %d")) - cache_manager = MagicMock() - cache_manager.get.return_value = schedule - cache_manager.cache_dir = str(tmp_path) - manager = cls(config, display_manager, cache_manager, - logging.getLogger("test_sports_base_characterization"), - "nhl") - # Safety net: any accidental network fetch must fail loudly. - manager.session = MagicMock() - manager.session.get.side_effect = requests.exceptions.ConnectionError( - "characterization tests are offline") - return manager - - return build - - -def _ids(games): - return [g["id"] for g in games] - - -@freeze_time(FROZEN_NOW) -class TestUpcomingUpdateFlow: - def test_populates_games_list_sorted_by_start_time(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule()) - manager.update() - # PINNED AS-IS: SportsUpcoming filters purely on is_upcoming - # (state 'pre') — there is NO date filter, so 'pre' games whose - # start time is already in the past (9003/9004 vs frozen 1/20) - # are still shown. - assert _ids(manager.games_list) == ["9003", "9004"] - assert manager.current_game["id"] == "9003" - - def test_filters_by_favorite_teams(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["BOS"]) - manager.update() - assert _ids(manager.games_list) == ["9004"] - assert manager.current_game["id"] == "9004" - - def test_favorites_only_with_no_favorites_shows_nothing( - self, build_manager): - # PINNED AS-IS: show_favorite_teams_only=True with an empty - # favorite_teams list drops every game rather than falling back - # to showing all games. - manager = build_manager(_UpcomingHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=[]) - manager.update() - assert manager.games_list == [] - assert manager.current_game is None - - def test_caps_at_upcoming_games_to_show(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - upcoming_games_to_show=1) - manager.update() - assert _ids(manager.games_list) == ["9003"] - - def test_tolerates_empty_events_list(self, build_manager): - manager = build_manager(_UpcomingHarness, {"events": []}) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - def test_tolerates_fetch_returning_none(self, build_manager): - manager = build_manager(_UpcomingHarness, None) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - def test_disabled_manager_update_is_noop(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - enabled=False) - manager.update() - assert manager.games_list == [] - manager.cache_manager.get.assert_not_called() - - -@freeze_time(FROZEN_NOW) -class TestRecentUpdateFlow: - def test_populates_only_finals_within_21_day_window(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule()) - manager.update() - # 9001 (final, 6 days old) kept; 9005 (final, ~80 days old) - # excluded by the 21-day cutoff; live/pre games excluded. - assert _ids(manager.games_list) == ["9001"] - assert manager.current_game["id"] == "9001" - assert manager.current_game["is_final"] is True - - def test_filters_by_favorite_teams(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["TOR"]) - manager.update() - assert _ids(manager.games_list) == ["9001"] - - stranger = build_manager(_RecentHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["XXX"]) - stranger.update() - assert stranger.games_list == [] - assert stranger.current_game is None - - def test_tolerates_empty_events_list(self, build_manager): - manager = build_manager(_RecentHarness, {"events": []}) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - -@freeze_time(FROZEN_NOW) -class TestLiveUpdateFlow: - def test_selects_only_live_games(self, build_manager): - manager = build_manager(_LiveHarness, make_schedule()) - manager.update() - assert _ids(manager.live_games) == ["9002"] - assert manager.current_game["id"] == "9002" - assert manager.current_game["is_live"] is True - - def test_no_live_games_clears_current_game(self, build_manager): - schedule = {"events": [ - make_event("9001", "post", "2026-01-14T00:00:00Z"), - make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0), - ]} - manager = build_manager(_LiveHarness, schedule) - manager.update() - assert manager.live_games == [] - assert manager.current_game is None - - -# --------------------------------------------------------------------------- -# 3. Rendering smoke — one display() per mode class at 128x32 -# --------------------------------------------------------------------------- - -def _fake_logo(*args, **kwargs): - return Image.new("RGBA", (24, 24), (180, 30, 30, 255)) - - -@freeze_time(FROZEN_NOW) -class TestRenderingSmoke: - def _assert_rendered(self, manager): - manager.display_manager.update_display.assert_called() - assert manager.display_manager.image.convert("L").getbbox() is not None - - def test_upcoming_display_draws_ink(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_recent_display_draws_ink(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_live_display_draws_ink(self, build_manager): - manager = build_manager(_LiveHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_draw_scorebug_layout_direct_call_does_not_raise( - self, build_manager): - # The base-class placeholder renderer must also stay callable. - manager = build_manager(_UpcomingHarness, make_schedule()) - game = manager._extract_game_details(make_schedule()["events"][2]) - manager._load_and_resize_logo = _fake_logo - SportsCore._draw_scorebug_layout(manager, game) - assert manager.display_manager.image.convert("L").getbbox() is not None - - -# --------------------------------------------------------------------------- -# 4. Guard rails — seams the merge must not silently drop -# --------------------------------------------------------------------------- - -class TestGuardRails: - def test_skin_seam_methods_survive(self): - for name in ("_resolve_skin_id", "_get_skin", "_render_game", - "render_skin_card"): - assert callable(getattr(SportsCore, name, None)), ( - f"SportsCore.{name} is part of the skin-system seam " - "(src/skin_system) — the sports-unification merge must " - "keep it.") - - def test_skin_mode_per_class(self): - assert SportsCore.SKIN_MODE == "live" - assert SportsUpcoming.SKIN_MODE == "upcoming" - assert SportsRecent.SKIN_MODE == "recent" - assert SportsLive.SKIN_MODE == "live" # inherits the default - - def test_core_display_and_extractor_seams_survive(self): - for name in ("display", "_draw_scorebug_layout", - "_extract_game_details_common", "update"): - owner = SportsCore if name != "update" else SportsUpcoming - assert callable(getattr(owner, name, None)), name diff --git a/test/test_sports_capabilities.py b/test/test_sports_capabilities.py deleted file mode 100644 index a3cd0f3f..00000000 --- a/test/test_sports_capabilities.py +++ /dev/null @@ -1,1077 +0,0 @@ -"""Tests for the opt-in sports capabilities (phase B2). - -Two properties matter beyond "the code works": - -1. **Opting out is structural.** A mode class that does not mix in - ``CelebrationMixin`` must have none of its attributes or methods — not - merely a disabled flag. ``TestOptOutIsStructural`` asserts that directly, - because it is the property the whole mixin design exists to buy. - -2. **The promoted behavior matches the plugin copies.** These bodies came from - afl/soccer/nrl (goal dialect) and football (score dialect); the tests pin - the reconciled behavior of both, including the three seams where the - lineages genuinely disagreed. - -See docs/SPORTS_UNIFICATION.md. -""" - -import sys -import time -from unittest.mock import MagicMock - -import pytest - -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports.capabilities import ( # noqa: E402 - CelebrationMixin, - RotationStrategy, - SimpleRotation, - SmoothWeightedRotation, - WeightedCycleRotation, - get_rotation_strategy, - register_rotation_strategy, -) - - -def game(gid, home="HOM", away="AWY", home_score=0, away_score=0, **extra): - g = { - "id": gid, - "home_abbr": home, - "away_abbr": away, - "home_id": f"{gid}-h", - "away_id": f"{gid}-a", - "home_score": home_score, - "away_score": away_score, - } - g.update(extra) - return g - - -# --------------------------------------------------------------------------- -# Rotation strategies -# --------------------------------------------------------------------------- - -def boost(favorites, factor=3): - """A weight_for callable of the shape the plugins supply.""" - return lambda g: factor if g.get("home_abbr") in favorites else 1 - - -class TestRegistry: - @pytest.mark.parametrize("name,cls", [ - ("simple", SimpleRotation), - ("weighted", WeightedCycleRotation), - ("swrr", SmoothWeightedRotation), - ]) - def test_builtin_names_resolve(self, name, cls): - assert isinstance(get_rotation_strategy(name), cls) - - def test_unknown_name_falls_back_to_simple(self): - """The name comes from user config; a typo should cost the boost, not - the scoreboard.""" - assert isinstance(get_rotation_strategy("typo"), SimpleRotation) - - def test_a_plugin_can_register_its_own(self): - class MyRotation(SimpleRotation): - pass - - register_rotation_strategy("test-only", MyRotation) - try: - assert isinstance(get_rotation_strategy("test-only"), MyRotation) - assert MyRotation.name == "test-only" - finally: - from src.base_classes.sports.capabilities import rotation - rotation._REGISTRY.pop("test-only", None) - - def test_empty_name_is_rejected(self): - with pytest.raises(ValueError): - register_rotation_strategy("", SimpleRotation) - - @pytest.mark.parametrize("bad", [ - SimpleRotation(), # an instance, not the class - str, # unrelated class - lambda **kw: None, # a factory function - ]) - def test_a_non_strategy_factory_is_rejected(self, bad): - """Fail at registration, not several frames later inside schedule(), - where the cause is no longer on the stack.""" - with pytest.raises(TypeError): - register_rotation_strategy("bad-factory", bad) - - def test_weight_for_is_optional(self): - """Default weights are equal, so every strategy degenerates to a plain - round robin — the pre-boost behavior.""" - games = [game("a"), game("b"), game("c")] - for name in ("simple", "weighted", "swrr"): - assert get_rotation_strategy(name).schedule(games) == ["a", "b", "c"] - - -class TestWeights: - def test_games_without_an_id_are_skipped(self): - strategy = get_rotation_strategy("weighted") - assert strategy.weights([game("a"), {"home_abbr": "X"}]) == {"a": 1} - - @pytest.mark.parametrize("bad", [0, -5]) - def test_non_positive_weights_are_clamped_to_one(self, bad): - """A zero weight would starve the game out of the rotation entirely and - collapse total_weight — no caller means that.""" - strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad) - assert strategy.weights([game("a")]) == {"a": 1} - - @pytest.mark.parametrize("bad", [None, "three", object()]) - def test_unusable_weights_fall_back_to_one(self, bad): - strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad) - assert strategy.weights([game("a")]) == {"a": 1} - - def test_huge_weights_are_clamped(self): - """A cycle is sum(weights) long and each step scans every game, so an - unbounded weight from a misread config spins the display thread.""" - strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000) - assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT} - - def test_a_clamped_cycle_stays_bounded(self): - strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000) - order = strategy.schedule([game("a"), game("b")]) - assert len(order) == 2 * RotationStrategy.MAX_WEIGHT - - -class TestSimpleRotation: - def test_one_pass_in_feed_order(self): - games = [game("a"), game("b"), game("c")] - assert SimpleRotation().schedule(games) == ["a", "b", "c"] - - def test_weights_are_ignored(self): - games = [game("a", home="FAV"), game("b")] - strategy = SimpleRotation(weight_for=boost({"FAV"}, 5)) - assert strategy.schedule(games) == ["a", "b"] - - def test_empty(self): - assert SimpleRotation().schedule([]) == [] - assert SimpleRotation().next_game([]) is None - - -class TestWeightedCycleRotation: - def test_no_boost_is_a_single_pass(self): - games = [game("a"), game("b"), game("c")] - strategy = WeightedCycleRotation(weight_for=boost({"NONE"})) - assert strategy.schedule(games) == ["a", "b", "c"] - - def test_favorite_gets_boost_many_slots(self): - games = [game("a", home="FAV"), game("b")] - order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games) - assert len(order) == 4 - assert order.count("a") == 3 - assert order.count("b") == 1 - - def test_repeats_are_spaced_not_clumped(self): - """The point of SWRR over naive repetition: 'aaab' is what we must NOT - produce.""" - games = [game("a", home="FAV"), game("b")] - order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games) - assert order != ["a", "a", "a", "b"] - assert order[0] == "a", "highest weight is scheduled first" - - def test_is_stateless_across_calls(self): - games = [game("a", home="FAV"), game("b")] - strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)) - assert strategy.schedule(games) == strategy.schedule(games) - - def test_next_game_returns_the_first_of_the_cycle(self): - games = [game("a"), game("b", home="FAV")] - strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 4)) - assert strategy.next_game(games)["id"] == "b" - - def test_empty(self): - assert WeightedCycleRotation().schedule([]) == [] - - -class TestSmoothWeightedRotation: - def test_no_boost_is_plain_round_robin(self): - games = [game("a"), game("b"), game("c")] - strategy = SmoothWeightedRotation() - assert [strategy.next_game(games)["id"] for _ in range(6)] == [ - "a", "b", "c", "a", "b", "c"] - - def test_favorite_wins_the_share_over_a_long_run(self): - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - picks = [strategy.next_game(games)["id"] for _ in range(40)] - assert picks.count("a") == 30 - assert picks.count("b") == 10 - - def test_no_clustering_seam_across_cycle_boundaries(self): - """The property that motivates keeping this strategy separate from the - precomputed one: state persists, so there is no restart every N picks - and therefore no place where repeats bunch up.""" - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - picks = [strategy.next_game(games)["id"] for _ in range(40)] - assert "aaaa" not in "".join(picks) - - def test_a_new_favorite_is_queued_first(self): - """A favorite's game that has just gone live starts at weight 0, gets - its full weight on the next call, and so wins the first pick after it - appears — without a special-cased branch.""" - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 5)) - for _ in range(3): - strategy.next_game(games) - games.append(game("c", home="FAV")) - assert strategy.next_game(games)["id"] == "c" - - def test_state_for_games_no_longer_live_is_dropped(self): - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation() - strategy.next_game(games) - strategy.next_game([game("a")]) - assert set(strategy._current) == {"a"} - - def test_reset_clears_state(self): - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation() - strategy.next_game(games) - strategy.reset() - assert strategy._current == {} - assert strategy.next_game(games)["id"] == "a" - - def test_schedule_previews_without_perturbing_state(self): - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - preview = strategy.schedule(games) - actual = [strategy.next_game(games)["id"] for _ in range(len(preview))] - assert preview == actual - - def test_preview_uses_the_subclass_ordering(self): - """schedule() promises the order repeated next_game calls produce. Built - from the base class, a subclass that overrides next_game gets a preview - of the wrong algorithm.""" - class Reversed(SmoothWeightedRotation): - def next_game(self, games): - return super().next_game(list(reversed(games))) - - games = [game("a"), game("b"), game("c")] - strategy = Reversed() - preview = strategy.schedule(games) - actual = [strategy.next_game(games)["id"] for _ in range(len(preview))] - assert preview == actual - - def test_empty(self): - assert SmoothWeightedRotation().next_game([]) is None - assert SmoothWeightedRotation().schedule([]) == [] - - def test_games_without_ids_are_ignored(self): - assert SmoothWeightedRotation().next_game([{"home_abbr": "X"}]) is None - - -class TestStrategiesAgreeWithinACycle: - """The survey's core finding: the 'three dialects' are one algorithm. They - must produce the same order within a cycle; they differ only at the - boundary, which is why both shapes survive.""" - - @pytest.mark.parametrize("factor", [2, 3, 5]) - def test_first_cycle_matches(self, factor): - games = [game("a", home="FAV"), game("b"), game("c")] - weight_for = boost({"FAV"}, factor) - assert (SmoothWeightedRotation(weight_for=weight_for).schedule(games) - == WeightedCycleRotation(weight_for=weight_for).schedule(games)) - - -# --------------------------------------------------------------------------- -# Differential: core strategies vs. the plugin implementations they replace -# --------------------------------------------------------------------------- - -BOOST = 3 - - -def _is_fav(g): - return g.get("home_abbr") == "FAV" - - -def _weight_for(g): - return BOOST if _is_fav(g) else 1 - - -class _PluginSwrr: - """afl / nrl / soccer ``_swrr_advance``, transcribed verbatim.""" - - favorite_live_boost = BOOST - - def _is_favorite_game(self, g): - return _is_fav(g) - - def advance(self, games): - if not games: - return None - weights = {} - for g in games: - gid = g.get("id") - if gid is None: - continue - weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1 - if not weights: - return None - if not hasattr(self, "_swrr_weights"): - self._swrr_weights = {} - self._swrr_weights = { - gid: w for gid, w in self._swrr_weights.items() if gid in weights} - for gid, w in weights.items(): - self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w - total_weight = sum(weights.values()) - ids_in_order = [g.get("id") for g in games if g.get("id") in weights] - best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid]) - self._swrr_weights[best_gid] -= total_weight - return next(g for g in games if g.get("id") == best_gid) - - -def _plugin_weighted_schedule(games): - """football / baseball / basketball ``_build_weighted_schedule``, verbatim.""" - if not games: - return [] - weights = {g["id"]: (BOOST if _is_fav(g) else 1) for g in games} - total_weight = sum(weights.values()) - if total_weight <= len(games): - return [g["id"] for g in games] - current_weight = {gid: 0 for gid in weights} - schedule = [] - for _ in range(total_weight): - for gid in weights: - current_weight[gid] += weights[gid] - picked = max(current_weight, key=lambda gid: current_weight[gid]) - current_weight[picked] -= total_weight - schedule.append(picked) - return schedule - - -def _plugin_rotation_schedule(games): - """hockey ``_build_rotation_schedule``, transcribed verbatim.""" - weights = [(g["id"], BOOST if _is_fav(g) else 1) for g in games] - total_weight = sum(w for _, w in weights) - if not weights or total_weight <= 0: - return [g["id"] for g in games] - current_weights = {gid: 0 for gid, _ in weights} - schedule = [] - for _ in range(total_weight): - best_id, best_current = None, None - for gid, w in weights: - current_weights[gid] += w - if best_current is None or current_weights[gid] > best_current: - best_id, best_current = gid, current_weights[gid] - current_weights[best_id] -= total_weight - schedule.append(best_id) - return schedule - - -def _cases(): - """Every live-game shape up to 4 games: each either a favorite or not. - - Exhaustive rather than random so the gate is deterministic — a rotation - regression must fail the same way on every run. - """ - import itertools - for size in range(1, 5): - for flags in itertools.product(("FAV", "OTH"), repeat=size): - yield [game(f"g{i}", home=abbr) for i, abbr in enumerate(flags)] - - -class TestMatchesThePluginImplementations: - """The promotion is only safe if these reproduce the plugin copies exactly. - - B5 deletes the bundled copies on the strength of this: each core strategy is - checked against the verbatim source it replaces, over every live-game shape - up to four games. - """ - - @pytest.mark.parametrize("games", list(_cases())) - def test_swrr_matches_the_incremental_plugin_picker(self, games): - plugin = _PluginSwrr() - core = SmoothWeightedRotation(weight_for=_weight_for) - # 60 picks: long enough to cross many cycle boundaries, where a - # state-handling divergence would show up. - assert ([plugin.advance(games)["id"] for _ in range(60)] - == [core.next_game(games)["id"] for _ in range(60)]) - - @pytest.mark.parametrize("games", list(_cases())) - def test_weighted_matches_the_football_lineage(self, games): - assert (_plugin_weighted_schedule(games) - == WeightedCycleRotation(weight_for=_weight_for).schedule(games)) - - @pytest.mark.parametrize("games", list(_cases())) - def test_weighted_matches_hockeys_loop_shape(self, games): - assert (_plugin_rotation_schedule(games) - == WeightedCycleRotation(weight_for=_weight_for).schedule(games)) - - -# --------------------------------------------------------------------------- -# Celebrations -# --------------------------------------------------------------------------- - -class _FakeLive: - """Stand-in for SportsLive: just the surface the mixin touches.""" - - def __init__(self, mode_config=None, favorite_teams=None): - self.mode_config = mode_config or {} - self.favorite_teams = favorite_teams or [] - self.logger = MagicMock() - self.display_manager = MagicMock() - self.is_enabled = True - self.current_game = None - self.last_game_switch = 0 - self.display_calls = [] - - def _favorite_key(self, game, side): - return game.get(f"{side}_abbr") - - def display(self, force_clear=False): - self.display_calls.append(force_clear) - return True - - -class _Celebrating(CelebrationMixin, _FakeLive): - pass - - -class _Coalescing(CelebrationMixin, _FakeLive): - COALESCE_SCORING_SEQUENCE = True - - def score_phrase(self, points, team_abbr): - return "TOUCHDOWN!" if points >= 6 else f"{team_abbr} FIELD GOAL!" - - -class _ById(CelebrationMixin, _FakeLive): - """The nrl shape: ambiguous abbreviations, so favorites match on team id.""" - - def _favorite_key(self, game, side): - return game.get(f"{side}_id") - - -@pytest.fixture -def celebrating(): - def _build(cls=_Celebrating, mode_config=None, favorites=None): - return cls(mode_config=mode_config, favorite_teams=favorites) - return _build - - -class TestOptOutIsStructural: - """The property the mixin design exists to buy: a class that does not opt in - has none of this code — not a disabled flag, not an unused attribute.""" - - def test_a_non_celebrating_class_has_no_celebration_surface(self): - plain = _FakeLive() - for attribute in ("active_celebration", "_score_baselines", - "celebration_enabled", "celebration_duration", - "_check_for_score", "_check_for_win", - "has_active_celebration", "_draw_celebration_layout"): - assert not hasattr(plain, attribute), ( - f"{attribute} leaked onto a class that never opted in") - - def test_the_mixin_is_absent_from_a_non_celebrating_mro(self): - assert CelebrationMixin not in _FakeLive.__mro__ - assert CelebrationMixin in _Celebrating.__mro__ - - def test_mixin_does_not_require_the_base_to_know_about_it(self): - """SportsLive must carry no celebration hooks — that would be the - god-class shape the mixin replaces.""" - from src.base_classes.sports import SportsLive - source = __import__("inspect").getsource(SportsLive) - assert "celebration" not in source.lower() - - -class TestCelebrationConfig: - def test_defaults(self, celebrating): - manager = celebrating() - assert manager.celebration_enabled is True - assert manager.celebration_duration == 8 - assert manager.celebrate_opponent_scores is False - assert manager.active_celebration is None - - def test_reads_the_goal_spelling_of_the_opponent_key(self, celebrating): - """The soccer lineage's published schema says `celebrate_opponent_goals`; - adopting the mixin must not silently reset users' setting.""" - manager = celebrating(mode_config={"celebrate_opponent_goals": True}) - assert manager.celebrate_opponent_scores is True - - def test_reads_the_score_spelling_of_the_opponent_key(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": True}) - assert manager.celebrate_opponent_scores is True - - def test_score_spelling_wins_when_both_are_present(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": False, - "celebrate_opponent_goals": True}) - assert manager.celebrate_opponent_scores is False - - @pytest.mark.parametrize("bad", ["eight", None, {}, []]) - def test_unusable_duration_falls_back(self, celebrating, bad): - """The duration is compared numerically on the display path, outside - any try block — a string from a hand-edited config would propagate a - TypeError straight out of display().""" - manager = celebrating(mode_config={"celebration_duration": bad}) - assert manager.celebration_duration == 8.0 - - @pytest.mark.parametrize("bad", [0, -5]) - def test_non_positive_duration_is_floored(self, celebrating, bad): - """Zero or negative would arm a celebration that can never render.""" - manager = celebrating(mode_config={"celebration_duration": bad}) - assert manager.celebration_duration == 1.0 - - def test_numeric_string_duration_is_accepted(self, celebrating): - assert celebrating( - mode_config={"celebration_duration": "12"}).celebration_duration == 12.0 - - -class TestScoreDetection: - def test_first_sighting_never_celebrates(self, celebrating): - """A game already in progress at boot must not false-fire.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - assert manager._score_baselines["g1"] == {"away": 1, "home": 3} - - def test_increment_arms_a_celebration(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=1, away_score=0)) - assert manager.active_celebration["kind"] == "score" - assert manager.active_celebration["scored_side"] == "home" - - def test_no_change_does_not_fire(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager._check_for_score(game("g1", home_score=2)) - assert manager.active_celebration is None - - def test_decrement_rebases_silently(self, celebrating): - """A disallowed goal / correction must not celebrate, and must not leave - a stale baseline that fires on the way back up.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.active_celebration is None - assert manager._score_baselines["g1"]["home"] == 1 - - def test_disabled_never_fires(self, celebrating): - manager = celebrating(mode_config={"celebration_enabled": False}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.active_celebration is None - - def test_game_without_an_id_is_ignored(self, celebrating): - manager = celebrating() - manager._check_for_score({"home_score": 1, "away_score": 0}) - assert manager.active_celebration is None - - @pytest.mark.parametrize("score", [None, "", "not-a-number-at-all"]) - def test_unusable_scores_are_ignored(self, celebrating, score): - manager = celebrating() - manager._check_for_score(game("g1", home_score=score)) - assert manager._score_baselines == {} - - @pytest.mark.parametrize("raw,expected", [ - ("7", 7), (7, 7), (7.0, 7), (" 7 ", 7), ("7 (SO)", 7), - ({"value": 7}, 7), ({"displayValue": "7"}, 7), - ]) - def test_score_coercion(self, raw, expected): - assert CelebrationMixin._score_to_int(raw) == expected - - def test_away_side_is_detected(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", away_score=0)) - manager._check_for_score(game("g1", away_score=1)) - assert manager.active_celebration["scored_side"] == "away" - - -class TestWhoGetsCelebrated: - def test_no_favorites_celebrates_everyone(self, celebrating): - """The user opted to show this game at all, so any score in it counts.""" - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home="XXX", home_score=0)) - manager._check_for_score(game("g1", home="XXX", home_score=1)) - assert manager.active_celebration is not None - - def test_favorite_scores(self, celebrating): - manager = celebrating(favorites=["FAV"]) - manager._check_for_score(game("g1", home="FAV", home_score=0)) - manager._check_for_score(game("g1", home="FAV", home_score=1)) - assert manager.active_celebration is not None - - def test_opponent_suppressed_by_default(self, celebrating): - manager = celebrating(favorites=["FAV"]) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0)) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1)) - assert manager.active_celebration is None - - def test_opponent_celebrated_when_opted_in(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": True}, - favorites=["FAV"]) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0)) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1)) - assert manager.active_celebration is not None - - def test_matching_goes_through_the_favorite_key_seam(self, celebrating): - """nrl matches on team id because its abbreviations are ambiguous - ('NEW' is both Newcastle and New Zealand). Core must not care why.""" - manager = celebrating(_ById, favorites=["g1-h"]) - manager._check_for_score(game("g1", home="NEW", home_score=0)) - manager._check_for_score(game("g1", home="NEW", home_score=1)) - assert manager.active_celebration is not None - - def test_favorite_key_seam_also_excludes(self, celebrating): - manager = celebrating(_ById, favorites=["someone-else"]) - manager._check_for_score(game("g1", home="NEW", home_score=0)) - manager._check_for_score(game("g1", home="NEW", home_score=1)) - assert manager.active_celebration is None - - -class TestPhrasing: - def test_default_phrase_is_sport_neutral(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home="HOM", home_score=0)) - manager._check_for_score(game("g1", home="HOM", home_score=1)) - assert manager.active_celebration["phrase"] == "HOM SCORES!" - - def test_score_phrase_hook_sees_the_points_delta(self, celebrating): - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - assert manager.active_celebration["phrase"] == "TOUCHDOWN!" - - def test_score_phrase_hook_distinguishes_smaller_plays(self, celebrating): - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=3)) - assert manager.active_celebration["phrase"] == "HOM FIELD GOAL!" - - def test_win_phrase(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=2, away_score=1)) - assert manager.active_celebration["phrase"] == "HOM WINS!" - - -class TestCoalescing: - def test_off_by_default_two_goals_are_two_celebrations(self, celebrating): - """Soccer/afl/nrl: consecutive increments are distinct events, so - suppressing the second would swallow a real goal.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - first = manager.active_celebration["started_at"] - manager._check_for_score(game("g1", home_score=2)) - assert manager.active_celebration["started_at"] != first - assert manager.active_celebration["home_score"] == 2 - - def test_on_suppresses_the_extra_point_follow_up(self, celebrating): - """Football: a touchdown lands as +6, then +1 seconds later. One - takeover per scoring sequence.""" - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - armed = manager.active_celebration - manager._check_for_score(game("g1", home_score=7)) - assert manager.active_celebration is armed - - def test_suppression_still_advances_the_baseline(self, celebrating): - """Nothing may re-fire once the window closes.""" - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - manager._check_for_score(game("g1", home_score=7)) - assert manager._score_baselines["g1"]["home"] == 7 - - -class TestWinDetection: - def test_win_requires_a_baseline(self, celebrating): - """A game seen for the first time already-final (board started after - full time) must not fire.""" - manager = celebrating(favorites=["HOM"]) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_win_fires_once_only(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - manager.active_celebration = None - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_draw_does_not_celebrate(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=2, away_score=2)) - assert manager.active_celebration is None - - def test_win_is_gated_strictly_on_favorites(self, celebrating): - """Unlike scores, a win with no favorites configured does NOT celebrate: - every game ends, so the fallback would be constant noise.""" - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_losing_favorite_does_not_celebrate(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=1, away_score=4)) - assert manager.active_celebration is None - - def test_away_favorite_wins(self, celebrating): - manager = celebrating(favorites=["AWY"]) - manager._check_for_score(game("g1", away_score=1)) - manager._check_for_win(game("g1", home_score=1, away_score=4)) - assert manager.active_celebration["scored_side"] == "away" - - -class TestCelebrationSnapshot: - def test_the_game_is_snapshotted_not_referenced(self, celebrating): - """A win must survive the game leaving live_games.""" - manager = celebrating() - live = game("g1", home_score=0) - manager._check_for_score(live) - live = game("g1", home_score=1) - manager._check_for_score(live) - live["home_abbr"] = "MUTATED" - assert manager.active_celebration["game"]["home_abbr"] == "HOM" - - def test_focus_is_pinned_to_the_involved_game(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.current_game["id"] == "g1" - - -class TestDisplayTakeover: - def test_no_celebration_defers_to_the_scorebug(self, celebrating): - manager = celebrating() - assert manager.display(force_clear=True) is True - assert manager.display_calls == [True] - - def test_active_celebration_takes_over(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock() - assert manager.display() is True - assert manager.display_calls == [], "the scorebug must not also render" - manager._draw_celebration_layout.assert_called_once() - - def test_expired_celebration_clears_and_defers(self, celebrating): - # A short-but-valid duration: celebration_duration is clamped to a 1.0s - # floor, so a config of 0 does NOT expire on the next frame. Backdate - # started_at past the window to exercise the real expiry branch — - # otherwise the celebration is still active and this only passes - # because _draw_celebration_layout happens to raise in the harness - # (that path is covered by test_a_render_failure_falls_through). - manager = celebrating(mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window - # Mock the layout so a render can't raise: otherwise the exception - # branch clears the celebration too, and this test would pass whether - # or not expiry actually fired. An expired celebration must NOT render. - manager._draw_celebration_layout = MagicMock() - assert manager.display() is True - manager._draw_celebration_layout.assert_not_called() - assert manager.active_celebration is None - assert manager.display_calls == [False] - - def test_expiry_resets_the_dwell_clock(self, celebrating): - """So the scorebug resumes on the scoring game for a full duration - before rotation can move on.""" - manager = celebrating(mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window - manager._draw_celebration_layout = MagicMock() # expiry, not a render failure - manager.display() - manager._draw_celebration_layout.assert_not_called() - assert manager.last_game_switch > 0 - - def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating): - """A broken celebration must never blank the display.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom")) - assert manager.display() is True - assert manager.display_calls == [False] - - def test_a_render_failure_disarms_rather_than_retrying(self, celebrating): - """Left armed, the same render fails on every frame for the rest of the - window — a traceback per frame, and no scorebug.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom")) - manager.display() - assert manager.active_celebration is None - manager.display() - assert manager._draw_celebration_layout.call_count == 1 - - -class TestBaselinePruning: - """`_score_baselines` gains an entry per game and only _check_for_win ever - removed one, so a board running all season grows the dict without bound.""" - - def test_prunes_games_no_longer_live(self, celebrating): - manager = celebrating() - for gid in ("g1", "g2", "g3"): - manager._check_for_score(game(gid, home_score=1)) - manager.prune_score_baselines([game("g2")]) - assert set(manager._score_baselines) == {"g2"} - - def test_keeps_every_still_live_game(self, celebrating): - manager = celebrating() - for gid in ("g1", "g2"): - manager._check_for_score(game(gid, home_score=1)) - manager.prune_score_baselines([game("g1"), game("g2")]) - assert set(manager._score_baselines) == {"g1", "g2"} - - def test_empty_live_set_clears_everything(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=1)) - manager.prune_score_baselines([]) - assert manager._score_baselines == {} - - def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager.prune_score_baselines([game("g1")]) - manager._check_for_score(game("g1", home_score=3)) - assert manager.active_celebration is not None, ( - "pruning must not drop a live game's baseline and re-trigger the " - "first-sighting suppression") - - def test_disabled_manager_renders_nothing(self, celebrating): - manager = celebrating() - manager.is_enabled = False - assert manager.display() is False - - -class TestFitFont: - def test_returns_the_first_font_that_fits(self, celebrating): - manager = celebrating() - draw = MagicMock() - draw.textlength.side_effect = [100, 20] - big, small = MagicMock(), MagicMock() - assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small - - def test_falls_back_to_the_smallest(self, celebrating): - manager = celebrating() - draw = MagicMock() - draw.textlength.return_value = 999 - big, small = MagicMock(), MagicMock() - assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small - - -class TestCapabilityExports: - @pytest.mark.parametrize("name", [ - "CelebrationMixin", "RotationStrategy", "SimpleRotation", - "SmoothWeightedRotation", "WeightedCycleRotation", - "get_rotation_strategy", "register_rotation_strategy", - ]) - def test_public_name_is_importable(self, name): - """Plugins import these behind a guarded fallback; the names are the - contract.""" - import src.base_classes.sports.capabilities as capabilities - assert hasattr(capabilities, name) - - def test_rotation_strategy_base_requires_a_schedule(self): - with pytest.raises(NotImplementedError): - RotationStrategy().schedule([game("a")]) - - -# --------------------------------------------------------------------------- -# Celebrations: rendering + previously untested edges -# --------------------------------------------------------------------------- - -from PIL import Image, ImageDraw, ImageFont # noqa: E402 - - -class _RenderableLive(_FakeLive): - """A _FakeLive that can actually execute _draw_celebration_layout: - real fonts, a display manager holding a real PIL image, and the two - SportsCore drawing seams the mixin calls.""" - - def __init__(self, mode_config=None, favorite_teams=None, - width=128, height=32, with_matrix=True): - super().__init__(mode_config=mode_config, favorite_teams=favorite_teams) - font = ImageFont.load_default() - self.fonts = {"time": font, "status": font, "score": font} - self.display_width = width - self.display_height = height - dm = MagicMock() - if with_matrix: - dm.matrix.width = width - dm.matrix.height = height - else: - dm.matrix = None - dm.image = Image.new("RGB", (width, height)) - self.display_manager = dm - self.logo_calls = [] - - def _load_and_resize_logo(self, team_id, abbr, path, url): - self.logo_calls.append(abbr) - logo = Image.new("RGBA", (10, 10), (0, 200, 0, 255)) - return logo - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255)): - draw.text(position, str(text), font=font, fill=fill) - - -class _RenderableCelebrating(CelebrationMixin, _RenderableLive): - pass - - -def _armed(manager, *, kind="score", side="home", started_ago=0.0): - manager._start_celebration( - game("g1", home_score=7, away_score=3), kind, - scored_side=side, team_abbr="HOM", away_score=3, home_score=7, - points=7, - ) - manager.active_celebration["started_at"] = time.time() - started_ago - return manager.active_celebration - - -class TestDrawCelebrationLayout: - """The takeover render path, executed for real (previously always - mocked out).""" - - def test_renders_and_hands_frame_to_display_manager(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - manager._draw_celebration_layout(celebration) - # The final frame was assigned and pushed. - assert isinstance(manager.display_manager.image, Image.Image) - assert manager.display_manager.image.mode == "RGB" - assert manager.display_manager.image.size == (128, 32) - manager.display_manager.update_display.assert_called_once() - assert manager.display_manager.image.convert("L").getbbox() is not None - - def test_force_clear_clears_display_first(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - manager._draw_celebration_layout(celebration, force_clear=True) - manager.display_manager.clear.assert_called_once() - - def test_flash_background_within_first_window(self): - # elapsed < 1.2 with int(elapsed/0.2) even -> flash color backdrop. - manager = _RenderableCelebrating() - celebration = _armed(manager, started_ago=0.05) - manager._draw_celebration_layout(celebration) - flash = manager.display_manager.image - # After the flash window: plain black backdrop. - celebration["started_at"] = time.time() - 5 - manager._draw_celebration_layout(celebration) - steady = manager.display_manager.image - # Corner pixels (away from logos/text) show the two backgrounds. - assert flash.getpixel((64, 30)) != steady.getpixel((64, 30)) or \ - flash.getpixel((3, 0)) != steady.getpixel((3, 0)) - - def test_matrix_dims_fallback_to_display_attrs(self): - manager = _RenderableCelebrating(width=96, height=48, with_matrix=False) - celebration = _armed(manager) - manager._draw_celebration_layout(celebration) - assert manager.display_manager.image.size == (96, 48) - - def test_highlight_color_alternates_with_elapsed(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - # int(elapsed*4) % 2 == 0 -> yellow; == 1 -> orange. Force each phase - # and diff the frames. - celebration["started_at"] = time.time() - 2.0 # 8 -> even - manager._draw_celebration_layout(celebration) - even = manager.display_manager.image.tobytes() - celebration["started_at"] = time.time() - 2.25 # 9 -> odd - manager._draw_celebration_layout(celebration) - odd = manager.display_manager.image.tobytes() - assert even != odd - - def test_logo_failure_still_renders_text(self): - manager = _RenderableCelebrating() - - def boom(*a, **k): - raise RuntimeError("disk gone") - - manager._load_and_resize_logo = boom - celebration = _armed(manager, started_ago=5) # steady background - manager._draw_celebration_layout(celebration) # must not raise - assert manager.display_manager.image.convert("L").getbbox() is not None - manager.display_manager.update_display.assert_called_once() - - -class TestCelebrationEdges: - def test_should_celebrate_for_three_way_branch(self, celebrating): - g = game("g1", home="FAV", away="OPP") - favored = celebrating(favorites=["FAV"]) - assert favored._should_celebrate_for(g, "home") is True # favorite - assert favored._should_celebrate_for(g, "away") is False # opponent - favored.celebrate_opponent_scores = True - assert favored._should_celebrate_for(g, "away") is True # opted in - unconfigured = celebrating(favorites=[]) - assert unconfigured._should_celebrate_for(g, "away") is True # no favs - - def test_active_celebration_boundary_is_strict(self, celebrating): - manager = celebrating(mode_config={"celebration_duration": 3}) - manager.active_celebration = {"started_at": time.time() - 3.0} - # elapsed == duration -> strictly-less-than comparison says done. - assert manager.has_active_celebration() is False - manager.active_celebration = None - assert manager.has_active_celebration() is False - - @pytest.mark.parametrize("value,expected", [ - ({"value": None}, None), # int(float(None)) TypeError -> caught - ({"value": "abc"}, None), - ({"other": 1}, 0), # neither key -> default 0 - ([3], None), # list -> TypeError -> caught - ("-4", None), # regex fallback finds digits -> 4? No: - ]) - def test_score_to_int_edges(self, value, expected): - result = CelebrationMixin._score_to_int(value) - if value == "-4": - # int(float("-4")) parses directly: -4. - assert result == -4 - else: - assert result == expected - - def test_both_teams_scoring_prefers_away(self, celebrating): - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=7, away_score=3)) - assert manager.active_celebration["scored_side"] == "away" - - def test_away_not_celebratable_falls_through_to_home(self, celebrating): - manager = celebrating(favorites=["HOM"]) # away is the opponent - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=7, away_score=3)) - assert manager.active_celebration["scored_side"] == "home" - - def test_coalesce_expired_celebration_fires_fresh(self, celebrating): - manager = celebrating(cls=_Coalescing, - mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1")) - manager._check_for_score(game("g1", home_score=6)) - first = manager.active_celebration - assert first is not None - first["started_at"] = time.time() - 2 # expired - manager._check_for_score(game("g1", home_score=7)) - # A new celebration replaced the expired one (coalescing only - # suppresses while one is actively on screen). - assert manager.active_celebration is not first - assert manager.active_celebration["home_score"] == 7 - - def test_disabled_win_check_preserves_baseline(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1")) - assert "g1" in manager._score_baselines - manager.celebration_enabled = False - manager._check_for_win(game("g1", home_score=7)) - # Early return BEFORE consuming the baseline: re-enabling later can - # still fire for this game. - assert "g1" in manager._score_baselines - - def test_prune_drops_baselines_for_idless_live_games(self, celebrating): - manager = celebrating() - manager._score_baselines = {"g1": {"away": 0, "home": 0}} - manager.prune_score_baselines([{"no_id_here": True}]) - # live ids collapse to {None}; g1 is not live -> dropped. - assert manager._score_baselines == {} diff --git a/test/test_sports_core_promotions.py b/test/test_sports_core_promotions.py deleted file mode 100644 index eb905002..00000000 --- a/test/test_sports_core_promotions.py +++ /dev/null @@ -1,613 +0,0 @@ -"""Tests for the methods promoted onto SportsCore from the nine bundled -plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md). - -Three methods and their seams land here: - -- ``cleanup()`` — byte-identical in all nine copies. The tests pin the - ordering (session close, then caches, then the completion log) and the - deliberate *omission*: the process-wide background service must never be - shut down by one unloading plugin. -- ``_get_layout_offset()`` — football's resolver-backed variant, with the - classic inline config read as the fallback used by every plugin that - doesn't hand core a ``_config_schema_path()``. -- ``_load_custom_font_from_element_config()`` — baseball's body (the only - copy that handles BDF strikes correctly) under hockey's wider signature, - resolving font files through the ``_font_root()`` seam instead of the - process cwd. -""" - -import ast -import json -import logging -import os -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image, ImageFont - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports import SportsCore - -LOGGER = logging.getLogger("test_sports_core_promotions") - -CORE_ROOT = Path(__file__).resolve().parents[1] -FONTS_DIR = CORE_ROOT / "assets" / "fonts" -TTF_NAME = "PressStart2P-Regular.ttf" -BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px -BDF_NATIVE_SIZE = 7 - - -class _StubSports(SportsCore): - """Minimal concrete SportsCore — the abstract methods are never called - by anything under test here.""" - - def _fetch_data(self): - return None - - def _extract_game_details(self, game_event): - return None - - -@pytest.fixture -def build(monkeypatch, tmp_path): - """Factory for real SportsCore instances: logo dir redirected to tmp and - the process-wide background service replaced with a MagicMock so the - tests can assert nothing ever calls it.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - def _build(config=None, cls=_StubSports): - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.cache_dir = str(tmp_path) - return cls(config if config is not None else {"timezone": "UTC"}, - display_manager, cache_manager, LOGGER, "nhl") - - return _build - - -def probe(config=None): - """Unbound-call stand-in for hosts we don't need a full instance for - (same pattern as make_probe in test_sports_base_characterization).""" - host = MagicMock() - host.logger = LOGGER - host.config = config if config is not None else {} - host._font_cache = {} - host._bdf_native_size_cache = {} - host._config_schema_path.return_value = None - host._font_root.side_effect = lambda: SportsCore._font_root(host) - host._resolve_font_path.side_effect = ( - lambda name: SportsCore._resolve_font_path(host, name)) - return host - - -def offset(host, element, axis, default=0): - return SportsCore._get_layout_offset(host, element, axis, default) - - -def load_font(host, *args, **kwargs): - return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs) - - -# --------------------------------------------------------------------------- -# 1. cleanup() -# --------------------------------------------------------------------------- - -class TestCleanup: - def test_closes_session_and_clears_all_caches(self, build): - manager = build() - session = MagicMock() - manager.session = session - manager._logo_cache["TB"] = object() - manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object() - manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7 - - manager.cleanup() - - session.close.assert_called_once_with() - assert manager._logo_cache == {} - # Promoted alongside the font loader: these hold PIL faces and are - # an unbounded leak across enable/disable cycles if never released. - assert manager._font_cache == {} - assert manager._bdf_native_size_cache == {} - - def test_second_cleanup_is_a_noop(self, build): - manager = build() - manager.session = MagicMock() - manager._logo_cache["TB"] = object() - manager._font_cache[("x", 8)] = object() - - manager.cleanup() - manager.cleanup() # must not raise on already-released state - - assert manager._logo_cache == {} - assert manager._font_cache == {} - assert manager._bdf_native_size_cache == {} - assert manager.session.close.call_count == 2 - - def test_does_not_shut_down_the_shared_background_service(self, build): - # get_background_service() hands out a PROCESS-WIDE singleton shared - # by every scoreboard. One plugin unloading must not stop background - # fetching for the other eight — cleanup() touches it not at all. - manager = build() - service = manager.background_service - manager.session = MagicMock() - - manager.cleanup() - - assert service.shutdown.called is False - assert service.stop.called is False - assert service.method_calls == [], ( - "cleanup() called into the shared background service: " - f"{service.method_calls}") - - def test_completion_is_logged_even_when_session_close_raises(self, build, caplog): - manager = build() - manager.session = MagicMock() - manager.session.close.side_effect = RuntimeError("socket already gone") - manager._logo_cache["TB"] = object() - - with caplog.at_level(logging.DEBUG, logger=LOGGER.name): - manager.cleanup() - - messages = [r.message for r in caplog.records] - assert any("Error closing session" in m for m in messages) - # Ordering is load-bearing: the caches still get cleared and the - # completion log still fires after a failed close. - assert manager._logo_cache == {} - assert any("cleanup completed" in m for m in messages) - - def test_tolerates_missing_attributes(self): - # The hasattr guards exist so a partially constructed instance (an - # __init__ that raised) can still be cleaned up. - host = MagicMock(spec=["logger"]) - host.logger = LOGGER - SportsCore.cleanup(host) - - -# --------------------------------------------------------------------------- -# 2. _get_layout_offset() + the _config_schema_path() seam -# --------------------------------------------------------------------------- - -def layout_config(element, axis, value): - return {"customization": {"layout": {element: {axis: value}}}} - - -class TestLayoutOffsetClassicPath: - """The default path: _config_schema_path() returns None, so offsets come - from the inline customization.layout read every plugin ships today.""" - - def test_config_schema_path_defaults_to_none(self, build): - manager = build() - assert manager._config_schema_path() is None - - def test_reads_configured_int(self): - host = probe(layout_config("home_logo", "x_offset", 5)) - assert offset(host, "home_logo", "x_offset") == 5 - - def test_float_is_truncated_to_int(self): - host = probe(layout_config("score", "y_offset", 2.9)) - result = offset(host, "score", "y_offset") - assert result == 2 and isinstance(result, int) - - def test_numeric_string_is_coerced(self): - host = probe(layout_config("score", "x_offset", "-3.5")) - assert offset(host, "score", "x_offset") == -3 - - def test_unconfigured_element_and_axis_use_default(self): - host = probe(layout_config("score", "x_offset", 5)) - assert offset(host, "status_text", "x_offset", 7) == 7 - assert offset(host, "score", "y_offset", -1) == -1 - assert offset(probe(), "score", "x_offset", 4) == 4 - - def test_non_numeric_string_degrades_to_default(self): - host = probe(layout_config("score", "x_offset", "left")) - assert offset(host, "score", "x_offset", 3) == 3 - - def test_unsupported_type_degrades_to_default(self): - host = probe(layout_config("score", "x_offset", {"nested": 1})) - assert offset(host, "score", "x_offset", 2) == 2 - host = probe(layout_config("score", "x_offset", None)) - assert offset(host, "score", "x_offset", 2) == 2 - - def test_broken_config_object_degrades_to_default(self): - host = probe() - host.config = "not a dict" - assert offset(host, "score", "x_offset", 6) == 6 - - def test_boolean_counts_as_one(self): - # PINNED AS-IS: the classic read predates the resolver and treats a - # bool as its int value (True -> 1). See the resolver test below for - # the stricter, more correct handling. - host = probe(layout_config("score", "x_offset", True)) - assert offset(host, "score", "x_offset", 4) == 1 - - -class TestLayoutOffsetResolverPath: - """When a plugin supplies its config_schema.json, offsets resolve through - src.element_style instead.""" - - @pytest.fixture - def schema_path(self, tmp_path): - path = tmp_path / "config_schema.json" - path.write_text(json.dumps({ - "type": "object", - "properties": { - "customization": { - "type": "object", - "properties": { - "layout": {"type": "object", "properties": {}}, - }, - }, - }, - })) - return str(path) - - def host(self, schema_path, config): - host = probe(config) - host._config_schema_path.return_value = schema_path - del host._style_resolver_cached # MagicMock auto-attrs otherwise - host._style_resolver_cached = None - return host - - def test_reads_configured_offsets(self, schema_path): - host = self.host(schema_path, layout_config("home_logo", "x_offset", 5)) - assert offset(host, "home_logo", "x_offset") == 5 - - def test_numeric_string_is_coerced(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", "-3.5")) - assert offset(host, "score", "x_offset") == -3 - - def test_missing_value_uses_default(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", 5)) - assert offset(host, "score", "y_offset", 9) == 9 - - def test_bad_input_degrades_to_default(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", "left")) - assert offset(host, "score", "x_offset", 3) == 3 - host = self.host(schema_path, {"customization": {"layout": "nope"}}) - assert offset(host, "score", "x_offset", 3) == 3 - - def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path): - # The intended behavior difference: a bool is not a pixel offset, so - # the resolver returns the default where the classic read returns 1. - host = self.host(schema_path, layout_config("score", "x_offset", True)) - assert offset(host, "score", "x_offset", 4) == 4 - - def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", 5)) - assert offset(host, "score", "x_offset") == 5 - first = host._style_resolver_cached - assert offset(host, "score", "x_offset") == 5 - assert host._style_resolver_cached is first - - # on_config_change swaps the dict object; the resolver must follow. - host.config = layout_config("score", "x_offset", 11) - assert offset(host, "score", "x_offset") == 11 - assert host._style_resolver_cached is not first - - def test_missing_schema_file_still_resolves_offsets(self, tmp_path): - # Offsets don't depend on schema defaults, so an unreadable schema - # must not cost the plugin its layout customization. - host = self.host(str(tmp_path / "absent.json"), - layout_config("score", "x_offset", 5)) - assert offset(host, "score", "x_offset") == 5 - - -# --------------------------------------------------------------------------- -# 3. _load_custom_font_from_element_config() + the _font_root() seam -# --------------------------------------------------------------------------- - -class TestFontRootSeam: - def test_default_font_root_is_the_core_install_root(self, build): - manager = build() - assert Path(manager._font_root()) == CORE_ROOT - assert (Path(manager._font_root()) / "assets" / "fonts").is_dir() - - def test_resolve_font_path_honors_an_overridden_root(self, tmp_path): - fonts = tmp_path / "assets" / "fonts" - fonts.mkdir(parents=True) - (fonts / "Bundled.ttf").write_bytes(b"not really a font") - host = probe() - host._font_root.side_effect = lambda: str(tmp_path) - assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str( - fonts / "Bundled.ttf") - - def test_unknown_font_returns_the_familiar_relative_path(self): - host = probe() - assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join( - "assets", "fonts", "Nope.ttf") - - -class TestFontLoaderSignature: - """Hockey's signature is the only safe superset: basketball's positional - ``default_font: str`` blows up on an explicit None.""" - - def test_two_arg_call(self): - font = load_font(probe(), {"font": TTF_NAME, "font_size": 10}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == 10 - - def test_default_size_is_used_when_config_omits_it(self): - assert load_font(probe(), {}, 12).size == 12 - - def test_three_positional_args(self): - font = load_font(probe(), {}, 6, "4x6-font.ttf") - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == 6 - assert font.path.endswith("4x6-font.ttf") - - def test_explicit_default_font_none(self): - # The regression this signature guards: os.path.join(..., None). - font = load_font(probe(), {"font_size": 9}, default_font=None) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.path.endswith(TTF_NAME) - - def test_config_font_wins_over_default_font(self): - font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf") - assert font.path.endswith(TTF_NAME) - - def test_string_font_size_is_coerced(self): - assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11 - - -class TestFontLoaderBehavior: - def test_family_alias_resolves_through_the_font_manager_catalog(self): - # "press_start" is a FontManager catalog family, not a filename; the - # promoted loader must not carry its own duplicate alias table. - host = probe() - font = load_font(host, {"font": "press_start", "font_size": 8}) - assert font.path.endswith(TTF_NAME) - assert ("PressStart2P-Regular.ttf", 8) in host._font_cache - - def test_memo_cache_returns_the_same_face(self): - host = probe() - first = load_font(host, {"font": TTF_NAME, "font_size": 8}) - second = load_font(host, {"font": TTF_NAME, "font_size": 8}) - assert first is second - assert len(host._font_cache) == 1 - # A different size is a different face. - assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first - assert len(host._font_cache) == 2 - - def test_bdf_loads_at_its_native_strike_when_the_request_misses(self): - # BDF is a fixed-size bitmap format: FreeType raises "invalid pixel - # size" for anything but the file's own strike. Baseball's retry is - # the only copy that gets this right. - host = probe() - font = load_font(host, {"font": BDF_NAME, "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == BDF_NATIVE_SIZE - assert font.path.endswith(BDF_NAME) - assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE} - # The retried face is memoized under the REQUESTED size. - assert host._font_cache[(BDF_NAME, 8)] is font - - def test_bdf_at_its_native_size_needs_no_retry(self): - host = probe() - font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE}) - assert font.size == BDF_NATIVE_SIZE - assert host._bdf_native_size_cache == {} - - def test_bdf_strike_lookup_is_memoized(self, monkeypatch): - calls = [] - real = SportsCore.__module__ - - def counting(path): - calls.append(path) - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(path) - - monkeypatch.setattr(f"{real}._read_bdf_native_size", counting) - host = probe() - load_font(host, {"font": BDF_NAME, "font_size": 8}) - host._font_cache.clear() # force the load path again - load_font(host, {"font": BDF_NAME, "font_size": 8}) - assert len(calls) == 1 - - def test_missing_font_falls_back_and_caches_the_fallback(self, caplog): - host = probe() - with caplog.at_level(logging.WARNING, logger=LOGGER.name): - font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.path.endswith(TTF_NAME) - assert any("Font file not found" in r.message for r in caplog.records) - # Cached under the requested name so a misconfiguration costs one - # disk probe, not one per frame. - assert host._font_cache[("DoesNotExist.ttf", 8)] is font - - def test_unknown_extension_falls_back(self): - host = probe() - font = load_font(host, {"font": "AUTHORS", "font_size": 8}) - assert font.path.endswith(TTF_NAME) - - def test_fallback_honors_the_supplied_default_font(self): - host = probe() - font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf") - assert font.path.endswith("4x6-font.ttf") - - -class TestFontLoaderCwdIndependence: - """The bug the _font_root() seam exists to prevent: every plugin copy - joins 'assets/fonts' onto the process cwd, so a process started anywhere - else silently degrades to PIL's default bitmap face (the same defect - already fixed in FontManager — see CHANGELOG Unreleased/Fixed).""" - - @pytest.mark.parametrize("font_name,expected_size", - [(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)]) - def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name, - expected_size): - monkeypatch.chdir("/") - host = probe() - font = load_font(host, {"font": font_name, "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont), ( - f"{font_name} degraded to PIL's default face when the process " - "runs outside the install root") - assert font.size == expected_size - assert Path(font.path) == FONTS_DIR / font_name - - def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch): - monkeypatch.chdir("/") - font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert Path(font.path) == FONTS_DIR / TTF_NAME - - @pytest.mark.parametrize("key", ["score", "time", "team", "status", - "detail", "rank"]) - def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key): - """`_load_fonts` had the same cwd-relative literals the seam exists to - remove; every scoreboard font silently became PIL's default bitmap face - when the process started outside the install root.""" - monkeypatch.chdir("/") - fonts = SportsCore._load_fonts(probe()) - assert isinstance(fonts[key], ImageFont.FreeTypeFont), ( - f"fonts['{key}'] degraded to PIL's default face outside the " - "install root") - - -class TestShouldLogCooldown: - """`_should_log` reads `self._last_warning_time` unguarded, so it must be - initialized in __init__ — otherwise the first warning of a run raises - AttributeError instead of logging.""" - - def test_cooldown_clock_is_initialized(self, build): - assert build()._last_warning_time == 0 - - def test_first_call_logs_then_cools_down(self, build): - manager = build() - assert manager._should_log("api", cooldown=60) is True - assert manager._should_log("api", cooldown=60) is False - - def test_cooldown_expires(self, build): - manager = build() - assert manager._should_log("api", cooldown=60) is True - manager._warning_cooldowns["api"] -= 61 - assert manager._should_log("api", cooldown=60) is True - - def test_cooldowns_are_tracked_per_warning_type(self, build): - """The parameter was accepted and ignored: one shared timestamp meant an - API warning silenced an unrelated cache warning for the next minute.""" - manager = build() - assert manager._should_log("api", cooldown=60) is True - assert manager._should_log("cache", cooldown=60) is True - assert manager._should_log("api", cooldown=60) is False - assert manager._should_log("cache", cooldown=60) is False - - def test_one_type_expiring_does_not_free_another(self, build): - manager = build() - manager._should_log("api") - manager._should_log("cache") - manager._warning_cooldowns["api"] -= 61 - assert manager._should_log("api") is True - assert manager._should_log("cache") is False - - def test_legacy_single_clock_field_is_kept_in_step(self, build): - """Subclasses in the plugin copies read _last_warning_time directly.""" - manager = build() - manager._should_log("api") - assert manager._last_warning_time == manager._warning_cooldowns["api"] - - -# --------------------------------------------------------------------------- -# 4. Seam guard rails -# --------------------------------------------------------------------------- - -class TestPromotedSeamsExist: - @pytest.mark.parametrize("name", [ - "cleanup", "_get_layout_offset", "_load_custom_font_from_element_config", - "_config_schema_path", "_font_root", "_resolve_font_path", - ]) - def test_method_is_callable_on_the_base_class(self, name): - assert callable(getattr(SportsCore, name, None)), ( - f"SportsCore.{name} is part of the promoted plugin-facing seam " - "(docs/SPORTS_UNIFICATION.md) — plugins probe for it with " - "hasattr before delegating.") - - def test_no_sport_names_leaked_into_core(self): - """core.py must never branch on which sport it is (prose and skin-id - examples in docstrings are fine — executable code is not).""" - tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports" - / "core.py").read_text()) - docstrings = set() - for node in ast.walk(tree): - if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, - ast.AsyncFunctionDef)): - first = node.body[0] if node.body else None - if (isinstance(first, ast.Expr) - and isinstance(first.value, ast.Constant) - and isinstance(first.value.value, str)): - docstrings.add(id(first.value)) - - tokens = [] - for node in ast.walk(tree): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - if id(node) not in docstrings: - tokens.append(node.value) - elif isinstance(node, ast.Name): - tokens.append(node.id) - elif isinstance(node, ast.Attribute): - tokens.append(node.attr) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, - ast.ClassDef)): - tokens.append(node.name) - - haystack = " ".join(tokens).lower() - for sport in ("afl", "nrl", "hockey", "baseball", "basketball", - "football", "lacrosse", "soccer", "ufc"): - assert sport not in haystack, ( - f"core.py code mentions '{sport}' — core must never learn " - "sport names; add an override point instead.") - - -class TestInstallRootResolution: - """Guards the depth bug the sports.py -> package move introduced. - - The move was byte-identical in every class body, but `__file__` gained a - directory, so `Path(__file__).resolve().parents[2]` silently changed from - the repo root to `/src`. Textual identity is not semantic identity - when code measures its own location: these tests assert the resolved - values, not the index. - """ - - def test_install_root_is_the_repo_root(self): - from src.base_classes.sports.core import _INSTALL_ROOT - - # The repo root is the directory that actually holds src/ and assets/. - assert (_INSTALL_ROOT / "src").is_dir() - assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir() - assert _INSTALL_ROOT.name != "src", ( - "_INSTALL_ROOT resolved to src/ — the parents[] depth is off by " - "one, which is exactly the regression the package move caused.") - - def test_resolve_project_path_roots_at_repo_not_src(self): - from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT - - resolved = SportsCore._resolve_project_path(None, Path("assets/fonts")) - assert resolved == _INSTALL_ROOT / "assets" / "fonts" - assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts - - def test_absolute_paths_pass_through_unchanged(self): - from src.base_classes.sports.core import SportsCore - - absolute = Path("/tmp/some/logo/dir") - assert SportsCore._resolve_project_path(None, absolute) == absolute - - def test_font_root_and_project_path_share_one_anchor(self): - """Both consumers must derive from the same constant, so a future - move needs exactly one line changed rather than two.""" - from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT - - assert SportsCore._font_root(None) == str(_INSTALL_ROOT) diff --git a/test/test_sports_logo_cache_bounded.py b/test/test_sports_logo_cache_bounded.py deleted file mode 100644 index 8570b81e..00000000 --- a/test/test_sports_logo_cache_bounded.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests that SportsCore's decoded-logo cache is LRU-bounded. - -``self._logo_cache`` was a plain dict keyed by team abbreviation, with no -eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about -36KB on a 256x64 panel, more for wide wordmarks -- and -assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league -therefore held the whole league in memory: roughly 11-18MB per manager -instance, and a league runs three of them (live/recent/upcoming) each with its -own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering. - -The bound has to be LRU rather than "clear when full": the logos on screen -right now are exactly the ones that must not be thrown away. -""" - -import logging -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports import SportsCore # noqa: E402 - -LOGGER = logging.getLogger("test_sports_logo_cache_bounded") - - -class _StubSports(SportsCore): - def _fetch_data(self): - return None - - def _extract_game_details(self, game_event): - return None - - -@pytest.fixture -def host(monkeypatch, tmp_path): - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - # Never reach for the network: every logo these tests ask for exists. - monkeypatch.setattr( - "src.base_classes.sports.core.download_missing_logo", - lambda *a, **k: None) - - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.cache_dir = str(tmp_path) - instance = _StubSports({"timezone": "UTC"}, display_manager, - cache_manager, LOGGER, "nhl") - instance._logo_dir = tmp_path - return instance, tmp_path - - -def _load(host, abbrev): - """Create a real logo file for `abbrev` and load it through the cache.""" - instance, tmp_path = host - path = tmp_path / f"{abbrev}.png" - if not path.exists(): - Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path) - return instance._load_and_resize_logo(abbrev, abbrev, path, None) - - -class TestTheCacheIsBounded: - def test_it_never_exceeds_the_limit(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit + 40): - assert _load(host, f"T{i}") is not None - # The regression: this grew to limit + 40, and for NCAA to 307. - assert len(instance._logo_cache) == limit - - def test_the_limit_is_smaller_than_a_real_league(self, host): - """ncaa_logos ships 307 files; the cap has to be well under that.""" - instance, _ = host - assert instance._LOGO_CACHE_MAX < 307 - - -class TestEvictionIsLRUNotArbitrary: - def test_the_least_recently_used_goes_first(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit): - _load(host, f"T{i}") - _load(host, "NEW") - assert "T0" not in instance._logo_cache, "oldest should have been evicted" - assert "NEW" in instance._logo_cache - - def test_touching_an_entry_saves_it(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit): - _load(host, f"T{i}") - _load(host, "T0") # a cache hit -- T0 is on screen again - _load(host, "NEW") # forces one eviction - - assert "T0" in instance._logo_cache, "a logo in use was thrown away" - assert "T1" not in instance._logo_cache, "T1 was the true LRU entry" - - -class TestHitsStillAvoidDiskWork: - def test_a_second_request_returns_the_cached_object(self, host, monkeypatch): - instance, _ = host - first = _load(host, "TB") - - def _boom(*a, **k): - raise AssertionError("cache hit re-opened the file from disk") - - monkeypatch.setattr(Image, "open", _boom) - assert _load(host, "TB") is first diff --git a/test/test_sports_modes_promotions.py b/test/test_sports_modes_promotions.py deleted file mode 100644 index 72f6a211..00000000 --- a/test/test_sports_modes_promotions.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Tests for the methods promoted onto SportsUpcoming / SportsRecent / -SportsLive from the nine plugin copies (phase B1 of the sports unification; -see docs/SPORTS_UNIFICATION.md). - -Covered promotions: -- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking` - (+ the `_zero_clock_timestamps` initializer). -- SportsLive: `_is_game_really_over` / `_detect_stale_games` - (+ `game_update_timestamps` / `stale_game_timeout`, and the - `FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes). -- SportsUpcoming: `_select_games_for_display`. -- SportsRecent: `_select_recent_games_for_display`. - -The live pair is the risk centre: `_detect_stale_games` is the only caller -that *removes* games, so `_is_game_really_over` returning a false positive -silently drops a live game from the display. The canonical form deliberately -declines to treat a missing clock as 0:00 — the plugin variant that did so -dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward. -That regression is pinned by -`test_missing_clock_at_late_period_is_not_over`. -""" - -import logging -import sys -import time -from datetime import datetime, timezone -from unittest.mock import MagicMock - -import pytest -import requests -from freezegun import freeze_time -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.hockey import Hockey, HockeyLive -from src.base_classes.sports import ( - SportsCore, - SportsLive, - SportsRecent, - SportsUpcoming, -) - - -# --------------------------------------------------------------------------- -# Harnesses -# --------------------------------------------------------------------------- - -class _UpcomingHarness(Hockey, SportsUpcoming): - """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data. - - `_favorite_key` is inherited from SportsCore — these harnesses - deliberately do NOT define it, so the selection tests exercise the real - seam rather than a local stand-in. - """ - - def _fetch_data(self): - return None - - -class _RecentHarness(Hockey, SportsRecent): - def _fetch_data(self): - return None - - -class _LiveHarness(HockeyLive): - def _fetch_data(self): - return None - - -class _ThreePeriodLiveHarness(_LiveHarness): - """Hockey-shaped: regulation ends after period 3.""" - - FINAL_PERIOD = 3 - - -class _CountUpLiveHarness(_LiveHarness): - """Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff.""" - - CLOCK_COUNTS_DOWN = False - - -class _IdFavoriteUpcomingHarness(_UpcomingHarness): - """NRL-shaped: abbreviations are ambiguous, so favorites match on team id.""" - - def _favorite_key(self, game, side): - team_id = game.get(f"{side}_id") - return str(team_id) if team_id is not None else None - - -class _IdFavoriteRecentHarness(_RecentHarness): - def _favorite_key(self, game, side): - team_id = game.get(f"{side}_id") - return str(team_id) if team_id is not None else None - - -@pytest.fixture -def build_manager(monkeypatch, tmp_path): - """Factory for concrete sports managers: mocked display/cache managers, - logo dir redirected to tmp, background service stubbed, and the requests - session rigged to prove nothing hits the network.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - def build(cls, **mode_cfg): - config = { - "timezone": "UTC", - "display": {}, - "nhl_scoreboard": {"enabled": True, **mode_cfg}, - } - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.get.return_value = None - cache_manager.cache_dir = str(tmp_path) - manager = cls(config, display_manager, cache_manager, - logging.getLogger("test_sports_modes_promotions"), - "nhl") - manager.session = MagicMock() - manager.session.get.side_effect = requests.exceptions.ConnectionError( - "promotion tests are offline") - return manager - - return build - - -def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None, - away_id=None, **extra): - g = { - "id": game_id, - "home_abbr": home, - "away_abbr": away, - "home_id": home_id, - "away_id": away_id, - "start_time_utc": start, - } - g.update(extra) - return g - - -def at(day, hour=12): - return datetime(2026, 1, day, hour, tzinfo=timezone.utc) - - -def _ids(games): - return [g["id"] for g in games] - - -# --------------------------------------------------------------------------- -# Tier 1 — zero-clock tracking (SportsRecent) -# --------------------------------------------------------------------------- - -class TestZeroClockTracking: - def test_initializer_present_and_empty(self, build_manager): - manager = build_manager(_RecentHarness) - assert manager._zero_clock_timestamps == {} - - def test_first_call_returns_zero_and_starts_tracking(self, build_manager): - manager = build_manager(_RecentHarness) - assert manager._get_zero_clock_duration("g1") == 0.0 - assert "g1" in manager._zero_clock_timestamps - - def test_subsequent_call_returns_elapsed_seconds(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - assert manager._get_zero_clock_duration("g1") == 0.0 - frozen.tick(45) - assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0) - frozen.tick(15) - assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0) - - def test_tracking_is_per_game(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - manager._get_zero_clock_duration("g1") - frozen.tick(30) - assert manager._get_zero_clock_duration("g2") == 0.0 - assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0) - - def test_clear_resets_tracking(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - manager._get_zero_clock_duration("g1") - frozen.tick(30) - manager._clear_zero_clock_tracking("g1") - assert "g1" not in manager._zero_clock_timestamps - # Restarts from zero after clearing. - assert manager._get_zero_clock_duration("g1") == 0.0 - - def test_clear_unknown_game_is_a_noop(self, build_manager): - manager = build_manager(_RecentHarness) - manager._clear_zero_clock_tracking("never-seen") # must not raise - assert manager._zero_clock_timestamps == {} - - -# --------------------------------------------------------------------------- -# Tier 2a — _is_game_really_over (SportsLive) -# --------------------------------------------------------------------------- - -class TestIsGameReallyOver: - def test_missing_clock_at_late_period_is_not_over(self, build_manager): - """THE baseball regression: no `clock` key at all, period 7. - - The rejected variant coerced a missing clock to the literal "0:00" and - declared the game over — dropping every MLB game from the 5th inning - onward, since baseball has no game clock and `period` is the inning. - A missing clock must fail safe. - """ - manager = build_manager(_LiveHarness) - g = game(period=7, period_text="Top 7th") - assert "clock" not in g - assert manager._is_game_really_over(g) is False - - def test_none_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=None, period=7, period_text="Top 7th")) is False - - def test_non_string_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=0, period=9, period_text="Bot 9th")) is False - - def test_blank_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=" ", period=5, period_text="5th")) is False - - @pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT", - "FINAL", "Final - SO"]) - def test_final_period_text_is_over(self, build_manager, period_text): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="12:00", period=2, period_text=period_text)) is True - - def test_none_period_text_does_not_raise(self, build_manager): - """All nine plugin copies called `.lower()` on `game.get("period_text", "")`, - which is None when the key is present-but-None; `_detect_stale_games` - has no try/except around the call.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="12:00", period=2, period_text=None)) is False - - def test_missing_period_text_does_not_raise(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over(game(clock="12:00", period=2)) is False - - @pytest.mark.parametrize( - "clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"]) - def test_expired_clock_at_final_period_is_over(self, build_manager, clock): - """Every spelling of a zeroed clock counts, not a hand-listed few. - - "00:00" is the one that motivated comparing numerically: it normalizes - to "0000", which matched none of the literals the plugin copies listed, - so a two-digit-minute expired clock kept the game on screen forever. - """ - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=clock, period=4, period_text="Q4")) is True - - def test_none_period_at_expired_clock_does_not_raise(self, build_manager): - """`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and - `_detect_stale_games` has no try/except — the same failure shape as the - `period_text` case above.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=None, period_text="Q4")) is False - - def test_none_period_with_running_clock_does_not_raise(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="8:12", period=None, period_text="Q2")) is False - - def test_expired_clock_after_final_period_is_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=5, period_text="OT")) is True - - def test_expired_clock_before_final_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=3, period_text="Q3")) is False - - @pytest.mark.parametrize("clock", [":40", "0:40", "1:00"]) - def test_running_clock_is_not_over(self, build_manager, clock): - """Sub-minute clocks like ':40' are legitimate, not expired.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=clock, period=4, period_text="Q4")) is False - - def test_defaults_are_four_period_countdown(self): - assert SportsLive.FINAL_PERIOD == 4 - assert SportsLive.CLOCK_COUNTS_DOWN is True - - def test_final_period_override_three(self, build_manager): - """Hockey-shaped subclass: regulation ends after period 3.""" - manager = build_manager(_ThreePeriodLiveHarness) - assert manager.FINAL_PERIOD == 3 - assert manager._is_game_really_over( - game(clock="0:00", period=3, period_text="P3")) is True - assert manager._is_game_really_over( - game(clock="0:00", period=2, period_text="P2")) is False - # And the unmodified default still requires period 4. - assert build_manager(_LiveHarness)._is_game_really_over( - game(clock="0:00", period=3, period_text="P3")) is False - - def test_count_up_clock_never_expires(self, build_manager): - """Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run.""" - manager = build_manager(_CountUpLiveHarness) - assert manager.CLOCK_COUNTS_DOWN is False - for period in (1, 2, 4, 9): - assert manager._is_game_really_over( - game(clock="0:00", period=period, period_text="1st Half")) is False - - def test_count_up_clock_still_honors_final_text(self, build_manager): - manager = build_manager(_CountUpLiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=2, period_text="Final")) is True - - -# --------------------------------------------------------------------------- -# Tier 2b — _detect_stale_games (SportsLive) -# --------------------------------------------------------------------------- - -class TestDetectStaleGames: - def test_initializer_defaults(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager.game_update_timestamps == {} - assert manager.stale_game_timeout == 300 - - def test_stale_timeout_is_configurable(self, build_manager): - manager = build_manager(_LiveHarness, stale_game_timeout=42) - assert manager.stale_game_timeout == 42 - - def test_mutates_caller_list_in_place_and_returns_none(self, build_manager): - manager = build_manager(_LiveHarness) - fresh = game("1", period_text="P2", clock="10:00", period=2) - over = game("2", period_text="Final", clock="0:00", period=3) - games = [fresh, over] - original = games - - result = manager._detect_stale_games(games) - - assert result is None - assert games is original # same object, mutated in place - assert _ids(games) == ["1"] - - def test_evicts_only_past_timeout_games(self, build_manager): - manager = build_manager(_LiveHarness) - with freeze_time("2026-01-20 12:00:00"): - now = time.time() - manager.game_update_timestamps = { - "1": {"last_seen": now - 10}, # fresh - "2": {"last_seen": now - 299}, # just inside the timeout - "3": {"last_seen": now - 301}, # past the timeout - } - games = [game("1", period_text="P1", clock="10:00", period=1), - game("2", period_text="P1", clock="10:00", period=1), - game("3", period_text="P1", clock="10:00", period=1)] - manager._detect_stale_games(games) - - assert _ids(games) == ["1", "2"] - assert "3" not in manager.game_update_timestamps - assert set(manager.game_update_timestamps) == {"1", "2"} - - def test_unknown_last_seen_is_never_stale(self, build_manager): - """last_seen == 0 (or no entry) means 'never recorded', not 'ancient'.""" - manager = build_manager(_LiveHarness) - games = [game("1", period_text="P1", clock="10:00", period=1), - game("2", period_text="P1", clock="10:00", period=1)] - manager.game_update_timestamps = {"1": {"last_seen": 0}} - manager._detect_stale_games(games) - assert _ids(games) == ["1", "2"] - - def test_removes_games_that_are_really_over(self, build_manager): - manager = build_manager(_LiveHarness) - manager.game_update_timestamps = {"2": {"last_seen": 0}} - games = [game("1", period_text="Q2", clock="5:00", period=2), - game("2", period_text="Final", clock="0:00", period=4)] - manager._detect_stale_games(games) - assert _ids(games) == ["1"] - assert "2" not in manager.game_update_timestamps - - def test_keeps_clockless_late_game(self, build_manager): - """The end-to-end form of the baseball regression: a clockless game in - the 7th must survive the removal path.""" - manager = build_manager(_LiveHarness) - games = [game("mlb-1", period=7, period_text="Top 7th")] - manager._detect_stale_games(games) - assert _ids(games) == ["mlb-1"] - - def test_games_without_id_are_skipped(self, build_manager): - manager = build_manager(_LiveHarness) - no_id = {"home_abbr": "BOS", "away_abbr": "TOR", - "period_text": "Final", "clock": "0:00", "period": 4} - games = [no_id] - manager._detect_stale_games(games) - # `continue` fires before the "really over" check, so it stays. - assert games == [no_id] - - def test_empty_list_is_tolerated(self, build_manager): - manager = build_manager(_LiveHarness) - games = [] - assert manager._detect_stale_games(games) is None - assert games == [] - - def test_removal_is_by_value_not_identity(self, build_manager): - """Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`, - so two structurally-equal dicts drop the FIRST occurrence.""" - manager = build_manager(_LiveHarness) - first = game("1", period_text="Final", clock="0:00", period=4) - twin = dict(first) - games = [first, twin] - manager._detect_stale_games(games) - # Both entries are removed here (two iterations, two removals), but the - # first removal deletes `first`, not the dict being iterated. - assert games == [] - - -# --------------------------------------------------------------------------- -# Tier 3a — _select_games_for_display (SportsUpcoming) -# --------------------------------------------------------------------------- - -class TestSelectGamesForDisplay: - def test_no_favorites_returns_all_sorted_ascending(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("late", start=at(20)), game("early", start=at(10)), - game("mid", start=at(15))] - assert _ids(manager._select_games_for_display(games, [])) == [ - "early", "mid", "late"] - - def test_missing_start_time_sorts_last(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("none", start=None), game("early", start=at(10))] - assert _ids(manager._select_games_for_display(games, [])) == [ - "early", "none"] - - def test_filters_to_favorite_teams(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("1", home="BOS", away="TOR", start=at(10)), - game("2", home="NYR", away="PIT", start=at(11)), - game("3", home="MTL", away="BOS", start=at(12))] - assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"] - - def test_respects_upcoming_games_to_show_per_team(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2) - games = [game(str(i), home="BOS", away="TOR", start=at(10 + i)) - for i in range(5)] - assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"] - - def test_game_between_two_favorites_counts_for_both(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1) - games = [game("shared", home="BOS", away="TOR", start=at(10)), - game("bos2", home="BOS", away="NYR", start=at(11)), - game("tor2", home="TOR", away="PIT", start=at(12))] - # "shared" fills both BOS's and TOR's single slot, so nothing else fits. - assert _ids(manager._select_games_for_display( - games, ["BOS", "TOR"])) == ["shared"] - - def test_deduplicates_by_game_id(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5) - g = game("dupe", home="BOS", away="TOR", start=at(10)) - assert _ids(manager._select_games_for_display( - [g, dict(g)], ["BOS"])) == ["dupe"] - - def test_non_favorite_games_excluded(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("1", home="NYR", away="PIT", start=at(10))] - assert manager._select_games_for_display(games, ["BOS"]) == [] - - def test_favorite_key_seam_supports_id_matching(self, build_manager): - """The NRL case: two clubs share the abbreviation 'NEW', so favorites - must be matched on team id. Only the seam changes — the promoted - method is identical.""" - games = [ - game("knights", home="NEW", away="SYD", home_id=1, away_id=2, - start=at(10)), - game("warriors", home="NEW", away="SYD", home_id=99, away_id=2, - start=at(11)), - ] - abbr_manager = build_manager(_UpcomingHarness) - # Abbreviation matching cannot tell the two "NEW" clubs apart. - assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [ - "knights", "warriors"] - - id_manager = build_manager(_IdFavoriteUpcomingHarness) - assert _ids(id_manager._select_games_for_display(games, ["99"])) == [ - "warriors"] - - def test_favorite_key_none_never_matches(self, build_manager): - """`_favorite_key` returning None (missing id) must not match, even - against a favorites list holding the string 'None'.""" - manager = build_manager(_IdFavoriteUpcomingHarness) - games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids - assert manager._select_games_for_display(games, ["None"]) == [] - - -# --------------------------------------------------------------------------- -# Tier 3b — _select_recent_games_for_display (SportsRecent) -# --------------------------------------------------------------------------- - -class TestSelectRecentGamesForDisplay: - def test_no_favorites_returns_all_sorted_descending(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("early", start=at(10)), game("late", start=at(20)), - game("mid", start=at(15))] - assert _ids(manager._select_recent_games_for_display(games, [])) == [ - "late", "mid", "early"] - - def test_missing_start_time_sorts_last(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("none", start=None), game("late", start=at(20))] - assert _ids(manager._select_recent_games_for_display(games, [])) == [ - "late", "none"] - - def test_respects_recent_games_to_show_per_team(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=2) - games = [game(str(i), home="BOS", away="TOR", start=at(10 + i)) - for i in range(5)] - # Most recent first. - assert _ids(manager._select_recent_games_for_display( - games, ["BOS"])) == ["4", "3"] - - def test_game_between_two_favorites_counts_for_both(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=1) - games = [game("shared", home="BOS", away="TOR", start=at(20)), - game("bos2", home="BOS", away="NYR", start=at(19)), - game("tor2", home="TOR", away="PIT", start=at(18))] - assert _ids(manager._select_recent_games_for_display( - games, ["BOS", "TOR"])) == ["shared"] - - def test_deduplicates_by_game_id(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=5) - g = game("dupe", home="BOS", away="TOR", start=at(10)) - assert _ids(manager._select_recent_games_for_display( - [g, dict(g)], ["BOS"])) == ["dupe"] - - def test_non_favorite_games_excluded(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("1", home="NYR", away="PIT", start=at(10))] - assert manager._select_recent_games_for_display(games, ["BOS"]) == [] - - def test_favorite_key_seam_supports_id_matching(self, build_manager): - games = [ - game("knights", home="NEW", away="SYD", home_id=1, away_id=2, - start=at(10)), - game("warriors", home="NEW", away="SYD", home_id=99, away_id=2, - start=at(11)), - ] - id_manager = build_manager(_IdFavoriteRecentHarness) - assert _ids(id_manager._select_recent_games_for_display( - games, ["99"])) == ["warriors"] - - -# --------------------------------------------------------------------------- -# Seam / inertness guards -# --------------------------------------------------------------------------- - -class TestPromotionShape: - def test_promoted_methods_live_on_the_right_classes(self): - assert hasattr(SportsRecent, "_get_zero_clock_duration") - assert hasattr(SportsRecent, "_clear_zero_clock_tracking") - assert hasattr(SportsRecent, "_select_recent_games_for_display") - assert hasattr(SportsUpcoming, "_select_games_for_display") - assert hasattr(SportsLive, "_is_game_really_over") - assert hasattr(SportsLive, "_detect_stale_games") - - def test_modes_does_not_define_the_favorite_key_seam(self): - """`_favorite_key` is a SportsCore override point. modes.py must call - it, never define it — this test fails if the seam is added in the - wrong file.""" - import src.base_classes.sports.modes as modes - - for cls in (SportsUpcoming, SportsRecent, SportsLive): - assert "_favorite_key" not in vars(cls) - assert "_favorite_key" not in modes.__dict__ diff --git a/test/test_sports_odds_fanout.py b/test/test_sports_odds_fanout.py deleted file mode 100644 index c33930e9..00000000 --- a/test/test_sports_odds_fanout.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Odds must be fetched for the games shown, not every game in the window. - -SportsUpcoming.update() collected every upcoming game in the schedule window -and called _fetch_odds() on each one *inside* that collection loop, narrowing -to upcoming_games_to_show only afterwards. The comment there said odds were -fetched "only for games that will be displayed", but the sole narrowing it -applied was show_favorite_teams_only, which is not the default -- so in the -usual configuration nothing narrowed it at all. - -Measured on a live rig: a college league produced 946 upcoming games in one -cycle and displayed 1 of them. The same shape on the football plugin produced -a burst of 467 sequential ESPN requests that ran for 35s and blew that -plugin's 30s update budget, and it repeats every time the 1h odds TTL expires. - -SportsLive is deliberately different: it walks the raw event list because it -has to find which games are live, but only fetches odds for a game that has -already passed the is_live/is_halftime test, so the fan-out is bounded by how -many games are actually in progress. -""" -import ast -from pathlib import Path - -import pytest - -MODES = (Path(__file__).resolve().parent.parent - / "src" / "base_classes" / "sports" / "modes.py") -TREE = ast.parse(MODES.read_text(encoding="utf-8")) - - -def _fetch_sites(): - """(class name, method name, lineno) for every self._fetch_odds(...) call.""" - calls = [n.lineno for n in ast.walk(TREE) - if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) - and n.func.attr == "_fetch_odds"] - sites = [] - for cls in [n for n in ast.walk(TREE) if isinstance(n, ast.ClassDef)]: - for fn in [n for n in cls.body if isinstance(n, ast.FunctionDef)]: - for lineno in calls: - if fn.lineno <= lineno <= (fn.end_lineno or fn.lineno): - sites.append((cls.name, fn.name, lineno)) - assert len(sites) == len(calls), "a _fetch_odds call sits outside any method" - return sites - - -def _innermost_loop_iterable(lineno): - best = None - for node in ast.walk(TREE): - if isinstance(node, ast.For) and \ - node.lineno <= lineno <= (node.end_lineno or node.lineno): - if best is None or node.lineno > best.lineno: - best = node - return None if best is None else ast.unparse(best.iter) - - -def _spans(body, lineno): - """True when `lineno` falls inside this list of statements.""" - return any(n.lineno <= lineno <= (n.end_lineno or n.lineno) for n in body) - - -def _parents(tree): - table = {} - for node in ast.walk(tree): - for child in ast.iter_child_nodes(node): - table[child] = node - return table - - -PARENTS = _parents(TREE) - - -def _mentions_positively(test, names): - """True when `test` references every name, none of them under a `not`. - - Structural, not textual. Matching the unparsed source would accept - `not (details["is_live"] or details["is_halftime"])` -- which selects - exactly the non-live games this guard exists to exclude -- because the - names still appear in the text. - """ - found = set() - for node in ast.walk(test): - if not (isinstance(node, ast.Constant) and node.value in names): - continue - negated = False - cursor = node - while cursor is not test and cursor in PARENTS: - cursor = PARENTS[cursor] - if isinstance(cursor, ast.UnaryOp) and isinstance(cursor.op, ast.Not): - negated = True - break - if not negated: - found.add(node.value) - return found >= set(names) - - -def _guarded_by_positive(lineno, names): - """True when some enclosing `if` runs this line only if `names` hold. - - Only the TRUE branch counts: an `if` whose `else` contains the call would - otherwise look like a guard while doing the opposite. - """ - for node in ast.walk(TREE): - if isinstance(node, ast.If) and _spans(node.body, lineno) \ - and _mentions_positively(node.test, names): - return True - return False - - -def test_every_fetch_site_is_accounted_for(): - """A new call site must be classified deliberately, not inherited silently.""" - found = {(cls, fn) for cls, fn, _ in _fetch_sites()} - assert found == {("SportsUpcoming", "update"), ("SportsLive", "update")}, ( - f"unexpected _fetch_odds call sites: {sorted(found)}. Each one is a " - "sequential ESPN request per game -- classify it here on purpose.") - - -def test_upcoming_fetches_only_the_selected_games(): - for cls, _fn, lineno in _fetch_sites(): - if cls != "SportsUpcoming": - continue - iterable = _innermost_loop_iterable(lineno) - assert iterable == "team_games", ( - f"SportsUpcoming._fetch_odds at line {lineno} iterates over " - f"{iterable!r}. It must run over team_games -- already narrowed to " - "upcoming_games_to_show -- not over every event in the schedule " - "window. Each item costs one sequential ESPN request.") - - -def test_live_only_fetches_for_games_actually_in_progress(): - for cls, _fn, lineno in _fetch_sites(): - if cls != "SportsLive": - continue - assert _guarded_by_positive(lineno, {"is_live", "is_halftime"}), ( - f"SportsLive._fetch_odds at line {lineno} does not sit in the true " - "branch of a test requiring the game to be in progress. Without " - "that, it fans out across the whole event list -- one sequential " - "ESPN request per game.") diff --git a/test/test_store_non_plugin_entries.py b/test/test_store_non_plugin_entries.py new file mode 100644 index 00000000..af2195a6 --- /dev/null +++ b/test/test_store_non_plugin_entries.py @@ -0,0 +1,79 @@ +"""Registry entries that aren't plugins are hidden and refused. + +The official registry no longer lists skins, but a custom registry (added +from the Plugin Store) can still carry ``"type": "skin"`` entries. With the +skin system removed, installing one as a plugin would unpack it into the +plugins directory, so the store hides such entries and refuses them at install. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from src.plugin_system.store_manager import PluginStoreManager +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401 + +PLUGIN = {"id": "clock", "name": "Clock", "repo": "https://github.com/x/clock"} +UNTYPED = {"id": "news", "name": "News", "repo": "https://github.com/x/news"} +SKIN = {"id": "retro", "name": "Retro", "type": "skin", "repo": "https://github.com/x/retro"} + + +@pytest.mark.parametrize("entry,expected", [ + (PLUGIN, True), + ({**PLUGIN, "type": "plugin"}, True), + ({**PLUGIN, "type": None}, True), + (SKIN, False), + ({**PLUGIN, "type": "theme"}, False), + (None, False), + ("clock", False), +]) +def test_is_plugin_entry(entry, expected): + assert PluginStoreManager.is_plugin_entry(entry) is expected + + +def test_install_refuses_a_non_plugin_entry(tmp_path): + store = PluginStoreManager(plugins_dir=str(tmp_path / "plugins")) + store.get_plugin_info = MagicMock(return_value=dict(SKIN)) + store._install_from_monorepo_zip = MagicMock() + store._install_via_git = MagicMock() + assert store._install_plugin_impl("retro") is False + assert not (tmp_path / "plugins" / "retro").exists() + store._install_from_monorepo_zip.assert_not_called() + store._install_via_git.assert_not_called() + + +@pytest.fixture +def store(api_v3_module): + mock = MagicMock() + mock.is_plugin_entry = PluginStoreManager.is_plugin_entry + api_v3_module.api_v3.plugin_store_manager = mock + return mock + + +def test_custom_registry_listing_hides_non_plugins(api_v3_client, store): + store.fetch_registry_from_url.return_value = {"plugins": [PLUGIN, SKIN, UNTYPED]} + resp = api_v3_client.post("/api/v3/plugins/registry-from-url", + data=json.dumps({"repo_url": "https://github.com/x/registry"}), + content_type="application/json") + assert resp.status_code == 200 + assert [p["id"] for p in resp.get_json()["plugins"]] == ["clock", "news"] + + +def test_store_listing_hides_non_plugins(api_v3_client, store): + store.search_plugins.return_value = [PLUGIN, SKIN, UNTYPED] + resp = api_v3_client.get("/api/v3/plugins/store/list") + assert resp.status_code == 200 + body = resp.get_json() + listed = body.get("data", body).get("plugins", []) + assert [p["id"] for p in listed] == ["clock", "news"] + + +def test_install_route_refuses_a_non_plugin_entry(api_v3_client, store): + store.get_registry_info.return_value = dict(SKIN) + resp = api_v3_client.post("/api/v3/plugins/install", + data=json.dumps({"plugin_id": "retro"}), + content_type="application/json") + assert resp.status_code == 400 + assert "not a plugin" in resp.get_json()["message"] + store.install_plugin.assert_not_called() diff --git a/test/test_version_comparison_consistency.py b/test/test_version_comparison_consistency.py index 9a27b59c..d111c4de 100644 --- a/test/test_version_comparison_consistency.py +++ b/test/test_version_comparison_consistency.py @@ -9,9 +9,9 @@ can never disagree. (Historically the store used raw string equality, which reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even DOWNGRADED locally-ahead plugins; this file's tests killed that.) -Two other version parsers legitimately remain and are pinned here so they -don't drift: `compatibility.parse_semver` (the install-compatibility gate, -range-spec oriented) and `skin_runtime._major` (skin API major gate). +One other version parser legitimately remains and is pinned here so it +doesn't drift: `compatibility.parse_semver` (the install-compatibility gate, +range-spec oriented). """ import json @@ -21,7 +21,6 @@ import pytest from packaging.version import parse as pkg_parse from src.plugin_system.compatibility import is_update_available, parse_semver -from src.skin_system.skin_runtime import _major from src.plugin_system.store_manager import PluginStoreManager from web_interface.blueprints.api_v3 import _is_plugin_update_available @@ -142,25 +141,6 @@ class TestStoreManagerUsesSharedComparator: reinstall.assert_not_called() -class TestSkinRuntimeMajor: - def test_plain_versions(self): - assert _major("1.0.0") == 1 - assert _major("2.1") == 2 - - def test_int_input_tolerated(self): - assert _major(2) == 2 - - def test_garbage_returns_none(self): - assert _major("garbage") is None - assert _major(None) is None - - def test_v_prefix_not_tolerated(self): - # Unlike parse_semver, _major does NOT strip a leading 'v' — - # a skin.json declaring "v1.0.0" fails the API gate. Characterized - # so a manifest-format loosening elsewhere doesn't silently diverge. - assert _major("v1.0.0") is None - - class TestParseSemverAgreesWithPackaging: """parse_semver and packaging must agree on ordering for plain X.Y.Z — the region where the two ecosystems overlap and must never diverge.""" diff --git a/test/web_interface/test_plugin_config_json_saves.py b/test/web_interface/test_plugin_config_json_saves.py index 5d0c50f6..6e3e9792 100644 --- a/test/web_interface/test_plugin_config_json_saves.py +++ b/test/web_interface/test_plugin_config_json_saves.py @@ -8,7 +8,9 @@ test_api_v3_secret_roundtrip.py, so assertions are on config.json itself. - Legacy booleans (#588) were normalized only at load, so posting back what GET /plugins/config returned failed validation. - The JSON save's filter kept only enabled/display_duration/live_priority, so - a submitted skin, skin_options or vegas_* tuning key was silently dropped. + a submitted vegas_* tuning key was silently dropped. +- ``skin`` and ``skin_options`` outlived the skin system in stored configs: + every save path must accept a section carrying them, and drop them. - Plugin sections posted to /config/main skipped all of that and were stored verbatim, including values /plugins/config rejects. """ @@ -65,7 +67,9 @@ STORED = { "display": {"brightness": 80, "show_icons": False}, "global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}}, "vegas_width_pct": 60, + # Written by a release that still had the skin system "skin": "neon", + "skin_options": {"accent": "#00ff00"}, } _ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager', @@ -174,28 +178,64 @@ class TestCoreOwnedKeysSurviveTheFilter: env.store({"enabled": True, "city": "Paris"}) resp = env.save({ "vegas_width_pct": 50, "vegas_overflow": "truncate", - "vegas_max_width_screens": 2, "skin": "retro", - "skin_options": {"accent": "#ff0000"}, "live_priority": True, + "vegas_max_width_screens": 2, "live_priority": True, }) assert resp.status_code == 200, resp.get_json() stored = env.stored() assert stored["vegas_width_pct"] == 50 assert stored["vegas_overflow"] == "truncate" assert stored["vegas_max_width_screens"] == 2 - assert stored["skin"] == "retro" - assert stored["skin_options"] == {"accent": "#ff0000"} assert stored["live_priority"] is True def test_stored_core_keys_survive_an_unrelated_save(self, env): assert env.save({"city": "Nice"}).status_code == 200 stored = env.stored() - assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon" + assert stored["vegas_width_pct"] == 60 def test_a_non_core_unknown_key_is_still_filtered(self, env): assert env.save({"not_in_schema": 1}).status_code == 200 assert "not_in_schema" not in env.stored() +class TestRetiredSkinKeys: + """STORED carries skin/skin_options, and SCHEMA sets + additionalProperties: false without declaring them.""" + + @staticmethod + def _assert_dropped(stored): + assert "skin" not in stored and "skin_options" not in stored + assert stored["vegas_width_pct"] == 60 # still a core-owned key + + def test_json_save_accepts_and_drops_them(self, env): + resp = env.save({"city": "Nice"}) + assert resp.status_code == 200, resp.get_json() + assert env.stored()["city"] == "Nice" + self._assert_dropped(env.stored()) + + def test_form_save_accepts_and_drops_them(self, env): + resp = env.client.post(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}", + data={"city": "Nice"}) + assert resp.status_code == 200, resp.get_json() + assert env.stored()["city"] == "Nice" + self._assert_dropped(env.stored()) + + def test_config_main_accepts_and_drops_them(self, env): + resp = env.client.post("/api/v3/config/main", json={PLUGIN_ID: {"city": "Nice"}}) + assert resp.status_code == 200, resp.get_json() + self._assert_dropped(env.stored()) + + def test_submitted_ones_are_not_stored(self, env): + env.store({"enabled": True, "city": "Paris", "vegas_width_pct": 60}) + resp = env.save({"skin": "retro", "skin_options": {"accent": "#ff0000"}}) + assert resp.status_code == 200, resp.get_json() + self._assert_dropped(env.stored()) + + def test_get_config_leaves_them_out(self, env): + data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"] + assert data["city"] == "Paris" + assert "skin" not in data and "skin_options" not in data + + class TestLegacyBooleans: LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}} @@ -246,7 +286,7 @@ class TestPluginSectionsInConfigMain: assert stored["city"] == "Lyon" assert stored["display"] == {"brightness": 80, "show_icons": False} assert stored["vegas_overflow"] == "rotate" - assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon" + assert stored["vegas_width_pct"] == 60 def test_secrets_still_go_to_the_secrets_file(self, env): resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}}) diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 4119a1d8..aa7f2d03 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -1261,9 +1261,8 @@ def _enhance_schema_with_core_properties(schema): """ Enhance schema with the core-owned per-plugin properties. - ``enabled``, ``display_duration``, ``live_priority``, ``skin``, - ``skin_options`` and the ``vegas_*`` tuning keys are system-managed and - always allowed, even when the plugin's schema doesn't declare them. The + ``enabled``, ``display_duration``, ``live_priority`` and the ``vegas_*`` + tuning keys are system-managed and always allowed, even when the plugin's schema doesn't declare them. The list is ``schema_manager.CORE_PLUGIN_PROPERTIES``, the one validation uses, so the save filter keeps exactly what validation accepts. diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 23458f24..0cc3eeda 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -1056,10 +1056,14 @@ def save_main_config(): if error: return error - # Deep merge regular config into main config + # Deep merge regular config into main config, dropping + # retired core keys (skin, skin_options) from the stored section + from src.plugin_system.schema_manager import drop_retired_plugin_keys stored_section = current_config.get(plugin_id) current_config[plugin_id] = deep_merge( - stored_section if isinstance(stored_section, dict) else {}, regular_config) + drop_retired_plugin_keys( + stored_section if isinstance(stored_section, dict) else {}, schema), + regular_config) if secrets_config: plugin_secrets_updates[plugin_id] = secrets_config diff --git a/web_interface/blueprints/api_v3/misc.py b/web_interface/blueprints/api_v3/misc.py index ddcb8790..8e49b7e7 100644 --- a/web_interface/blueprints/api_v3/misc.py +++ b/web_interface/blueprints/api_v3/misc.py @@ -1,5 +1,5 @@ """Routes with no larger group of their own: errors, integrations, -cache, sync, skins, logs, health and hardware. +cache, sync, logs, health and hardware. Routes decorate the shared `api_v3` Blueprint from ._common, so their endpoint names are unchanged by living here. @@ -150,51 +150,6 @@ def get_hardware_status(): except Exception: logger.error("Unexpected error reading hardware status", exc_info=True) return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 -@api_v3.route('/skins', methods=['GET']) -def list_skins(): - """List installed visual skins (docs/SKIN_SYSTEM.md). - - Optional ?plugin_id=... filters to skins matching that plugin. - - The response carries ``supported: false`` and a ``message``: the current - scoreboard plugins don't render skins, so a client must not present - these as selectable. - """ - try: - from src.skin_system import ( - SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime, - ) - - plugin_id = request.args.get('plugin_id') - if plugin_id: - skins = skin_runtime.skins_for_plugin(plugin_id) - else: - # The discovery cache self-invalidates on directory/manifest - # mtime changes, so no force_refresh — keeps Pi disk I/O down. - skins = skin_runtime.discover_skins() - - payload = [] - for skin_id, manifest in sorted(skins.items()): - skin_dir = Path(manifest['_skin_dir']) - preview = manifest.get('preview') - payload.append({ - 'id': skin_id, - 'name': manifest.get('name', skin_id), - 'version': manifest.get('version'), - 'author': manifest.get('author'), - 'description': manifest.get('description', ''), - 'skin_api_version': manifest.get('skin_api_version'), - 'targets': manifest.get('targets', {}), - 'modes': manifest.get('modes', []), - 'has_preview': bool(preview and (skin_dir / preview).is_file()), - }) - data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED} - if not SKINS_RENDER_SUPPORTED: - data['message'] = SKINS_UNSUPPORTED_MESSAGE - return jsonify({'status': 'success', 'data': data}) - except Exception as e: - logger.error('Error in list_skins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/logs', methods=['GET']) def get_logs(): """Get system logs from journalctl""" diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index eba6b56e..b293d321 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -1296,15 +1296,16 @@ def install_plugin(): plugin_id = data['plugin_id'] branch = data.get('branch') # Optional branch parameter - # A registry skin would install but never render with the current - # scoreboards; refuse it with the reason rather than a generic failure + # A registry entry that isn't a plugin (a custom registry can still + # list old "type": "skin" entries) gets a clear refusal, not a failed + # install. try: registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id) except Exception: registry_entry = None - if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin': - from src.skin_system import SKINS_UNSUPPORTED_MESSAGE - return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400 + if isinstance(registry_entry, dict) and not api_v3.plugin_store_manager.is_plugin_entry(registry_entry): + return jsonify({'status': 'error', + 'message': f"{plugin_id} is a {registry_entry.get('type')!r} entry, not a plugin"}), 400 # Install the plugin # Log the plugins directory being used for debugging @@ -1506,13 +1507,10 @@ def get_registry_from_url(): registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) if registry: - # Skins aren't offered: current scoreboards don't render them return jsonify({ 'status': 'success', - 'plugins': [ - p for p in registry.get('plugins', []) - if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin') - ], + 'plugins': [p for p in registry.get('plugins', []) + if api_v3.plugin_store_manager.is_plugin_entry(p)], 'registry_url': repo_url }) else: @@ -1627,9 +1625,7 @@ def list_plugin_store(): # Format plugins for the web interface formatted_plugins = [] for plugin in plugins: - # Registry skins install but never render with the current - # scoreboards, so the store doesn't offer them - if (plugin.get('type') or 'plugin') == 'skin': + if not api_v3.plugin_store_manager.is_plugin_entry(plugin): continue formatted_plugins.append({ 'id': plugin.get('id'), @@ -2210,7 +2206,10 @@ def save_plugin_config(): if plugin_id not in current_config: current_config[plugin_id] = {} - current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) + # Retired core keys (skin, skin_options) leave the stored section here + from src.plugin_system.schema_manager import drop_retired_plugin_keys + current_config[plugin_id] = deep_merge( + drop_retired_plugin_keys(current_config[plugin_id], schema), regular_config) # Deep merge plugin secrets in secrets config if secrets_config: @@ -2755,11 +2754,6 @@ def get_plugin_schema(): schema = schema_mgr.load_schema(plugin_id, use_cache=True) if schema: - # No "Visual Skin" dropdown: the current scoreboard plugins don't - # render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so - # schema_mgr.inject_skin_selector is deliberately not called. A - # stored "skin" value is unaffected: validation still allows it - # and a form save deep-merges over the stored section, keeping it. return jsonify({'status': 'success', 'data': {'schema': schema}}) # Return a simple default schema if file not found From 9a1f94f7939c256259ce9c2ca46ff0b51d7d4c52 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:48:02 -0400 Subject: [PATCH 4/4] fix(starlark): blank app locations use the device location, not San Francisco (#617) * fix(starlark): blank app locations use the device location, not San Francisco A Starlark (Tidbyt) app whose Location field is blank rendered at its author's hard-coded DEFAULT_LOCATION -- usually San Francisco -- even with the device city set under General settings. A user in Charlotte, NC got San Francisco weather and radar with nothing in config.json to explain it. src/device_location.py fills unset location fields at render time (display plugin and the web standalone render): the device city is geocoded once via Open-Meteo, preferring a match in the configured state/country, and cached permanently. A saved location always wins; if the lookup fails the field is dropped so the app uses its own default, and the failure is not retried for 30 minutes. Also fixes the config form: clearing a location omitted the key, and the save merges, so the old value could never be removed. Co-Authored-By: Claude Opus 5.5 * docs(starlark): say what happens when the device location can't be used A blank app Location only renders at the device's city when one is set and the Open-Meteo lookup finds it. With no city, no match, or the geocoder unreachable (retried after 30 minutes), the app gets no location and keeps its author's default. The guide, the config page hint, CONFIG_REFERENCE and the CHANGELOG entry now say so. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 8 + docs/CONFIG_REFERENCE.md | 2 +- docs/STARLARK_APPS_GUIDE.md | 9 +- plugin-repos/starlark-apps/manager.py | 9 + src/device_location.py | 330 ++++++++++++++++++ test/test_device_location.py | 315 +++++++++++++++++ .../test_starlark_pixlet_routes.py | 56 +++ web_interface/blueprints/api_v3/__init__.py | 30 ++ .../v3/partials/starlark_config.html | 8 +- 9 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 src/device_location.py create mode 100644 test/test_device_location.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e07b552..1afa4095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ accepts both, but the store flags the old spelling as deprecated that will now render the font it asked for. - `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for the display are written (`config/wifi_status.json`). +- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app + now renders at the device's City / State / Country (geocoded once via + Open-Meteo and cached) instead of the app author's hard-coded default, + usually San Francisco. A location saved on the app still wins. With no + device city set, or when the lookup fails or finds no match, the app keeps + its own default (a failed lookup is retried after 30 minutes). Clearing an + app's location in the web UI now actually clears it; the save used to drop + the blank field, so the old value stayed. Deprecated, removed in 3.7.0 (each logs a warning on first use; see `docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 58eb7df7..ac6b1486 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -19,7 +19,7 @@ tooling against it. | `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) | | `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` | | `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` | -| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config | +| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps | ## `schedule` — display on/off hours diff --git a/docs/STARLARK_APPS_GUIDE.md b/docs/STARLARK_APPS_GUIDE.md index 522b275e..efd5b77d 100644 --- a/docs/STARLARK_APPS_GUIDE.md +++ b/docs/STARLARK_APPS_GUIDE.md @@ -127,7 +127,7 @@ Verify installation: 3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc. 4. Click **Install** on desired apps 5. Configure each app: - - Set location/timezone + - Set location/timezone (optional: blank uses this device's location) - Enter API keys if required - Customize display preferences @@ -137,7 +137,12 @@ Each app may have different configuration options: #### Common Configuration Types -- **Location** (lat/lng/timezone): For weather, clocks, transit +- **Location** (lat/lng/timezone): For weather, clocks, transit. Left blank, + the app renders at this device's location (City / State / Country under + General settings). If no city is set there, or the city can't be looked up + (no match, or the geocoder is unreachable -- retried after 30 minutes), the + app gets no location and falls back to its author's hard-coded default, + usually San Francisco. Fill it in only to point one app somewhere else. - **API Keys**: For services like weather, stocks, sports scores - **Display Preferences**: Colors, units, layouts - **Dropdown Options**: Team selections, language, themes diff --git a/plugin-repos/starlark-apps/manager.py b/plugin-repos/starlark-apps/manager.py index e078ec55..fae59a97 100644 --- a/plugin-repos/starlark-apps/manager.py +++ b/plugin-repos/starlark-apps/manager.py @@ -19,6 +19,7 @@ from PIL import Image from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode from src.logging_config import get_logger +from src.device_location import DeviceLocationResolver, apply_device_location from pixlet_renderer import PixletRenderer from frame_extractor import FrameExtractor @@ -229,6 +230,10 @@ class StarlarkAppsPlugin(BasePlugin): self.current_app: Optional[StarlarkApp] = None self.last_update_check = 0 + # Unset location fields render at the device's location, not the + # app author's default (usually San Francisco). + self.device_location = DeviceLocationResolver(cache_manager, self.logger) + # Check Pixlet availability if not self.pixlet.is_available(): self.logger.error("Pixlet not available - Starlark apps will not work") @@ -882,6 +887,10 @@ class StarlarkAppsPlugin(BasePlugin): # Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'} pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS} + # Applied here rather than saved into config.json, so a later + # change to the device location reaches the next render. + pixlet_config = apply_device_location( + pixlet_config, app.schema, self.device_location, self.global_config) success, error = self.pixlet.render( star_file=str(app.star_file), diff --git a/src/device_location.py b/src/device_location.py new file mode 100644 index 00000000..1742b94c --- /dev/null +++ b/src/device_location.py @@ -0,0 +1,330 @@ +""" +The device's own location, in the shape a Starlark (Tidbyt/Pixlet) app expects. + +A Pixlet ``schema.Location`` field is a JSON string -- ``{"lat": "35.2271", +"lng": "-80.8431", "timezone": "America/New_York", ...}`` -- and an app whose +field is left unset falls back to whatever its author hard-coded. Most +community apps hard-code San Francisco, so a user who set Charlotte under +General settings got San Francisco weather and a San Francisco radar map with +nothing in config.json to explain it. + +Regular plugins already default their ``location_city``/``location_state``/ +``location_country`` keys to the device location +(``SchemaManager.apply_device_location``). This module is the Starlark +equivalent. The device ``location`` block only has city/state/country, so the +city is geocoded once (Open-Meteo, the same service ledmatrix-weather uses) +and the coordinates are cached permanently -- cities don't move, so the +geocoder is only hit on a cache miss. + +The substitution is applied at render time and never written into an app's +config.json, so a later change to the device location is picked up by the +next render. A location saved on the app itself always wins. +""" + +import json +import logging +import time +from typing import Any, Callable, Dict, Iterable, List, Optional + +GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +GEOCODE_TIMEOUT = 10 +#: More than the handful ledmatrix-weather asks for: a common name +#: (Springfield, Charlotte, Portland) has several US matches, and the one in +#: the configured state has to be among the results to be picked. +GEOCODE_RESULT_COUNT = 10 + +#: Coordinates for a fixed city never go stale. +COORDS_MAX_AGE = 10 * 365 * 24 * 3600 +#: After a failed lookup, renders use the app's own default until this has +#: passed, so a geocoder outage costs one timeout, not one per render. +FAILURE_RETRY_SECONDS = 30 * 60 + +CACHE_KEY_PREFIX = "device_location:coords" + +LOCATION_FIELD_TYPES = ("location",) + +# Open-Meteo reports the full state name in ``admin1``; the device state may be +# typed either way. +US_STATE_NAMES = { + "AL": "alabama", "AK": "alaska", "AZ": "arizona", "AR": "arkansas", + "CA": "california", "CO": "colorado", "CT": "connecticut", + "DE": "delaware", "DC": "district of columbia", "FL": "florida", + "GA": "georgia", "HI": "hawaii", "ID": "idaho", "IL": "illinois", + "IN": "indiana", "IA": "iowa", "KS": "kansas", "KY": "kentucky", + "LA": "louisiana", "ME": "maine", "MD": "maryland", + "MA": "massachusetts", "MI": "michigan", "MN": "minnesota", + "MS": "mississippi", "MO": "missouri", "MT": "montana", + "NE": "nebraska", "NV": "nevada", "NH": "new hampshire", + "NJ": "new jersey", "NM": "new mexico", "NY": "new york", + "NC": "north carolina", "ND": "north dakota", "OH": "ohio", + "OK": "oklahoma", "OR": "oregon", "PA": "pennsylvania", + "PR": "puerto rico", "RI": "rhode island", "SC": "south carolina", + "SD": "south dakota", "TN": "tennessee", "TX": "texas", "UT": "utah", + "VT": "vermont", "VA": "virginia", "WA": "washington", + "WV": "west virginia", "WI": "wisconsin", "WY": "wyoming", +} + +_COUNTRY_ALIASES = {"usa": "us", "united states": "us", + "united states of america": "us", "uk": "gb", + "united kingdom": "gb"} + +logger = logging.getLogger(__name__) + + +def _norm(value: Any) -> str: + """Lower-case, with ``_``/``-`` read as spaces ("North_Carolina").""" + if not isinstance(value, str): + return "" + return " ".join(value.replace("_", " ").replace("-", " ").lower().split()) + + +def _norm_state(value: Any) -> str: + state = _norm(value) + return US_STATE_NAMES.get(state.upper(), state) + + +def _norm_country(value: Any) -> str: + country = _norm(value) + return _COUNTRY_ALIASES.get(country, country) + + +def location_field_ids(schema: Optional[Dict[str, Any]]) -> List[str]: + """Ids of a Starlark app schema's ``location`` fields.""" + if not isinstance(schema, dict): + return [] + fields = schema.get("fields") or schema.get("schema") or [] + ids = [] + for field in fields: + if not isinstance(field, dict) or not field.get("id"): + continue + # "typeOf" from both extractors; "type" if a raw pixlet schema slipped + # through unremapped. + field_type = field.get("typeOf", field.get("type")) + if isinstance(field_type, str) and field_type.lower() in LOCATION_FIELD_TYPES: + ids.append(field["id"]) + return ids + + +def parse_location(value: Any) -> Optional[Dict[str, Any]]: + """The saved location as a dict, or None if it has no usable lat/lng. + + Blank, missing, unparseable, or lat/lng-less values (the config form sends + ``{"timezone": ...}`` when only the timezone box is filled) all mean the + user has not given the app a place. + """ + if isinstance(value, dict): + loc = value + elif isinstance(value, str) and value.strip(): + try: + loc = json.loads(value) + except (TypeError, ValueError): + return None + else: + return None + if not isinstance(loc, dict): + return None + try: + float(loc["lat"]) + float(loc["lng"]) + except (KeyError, TypeError, ValueError): + return None + return loc + + +def pick_geocode_result(results: Iterable[Dict[str, Any]], state: Any, + country: Any) -> Optional[Dict[str, Any]]: + """Best geocoder hit: same country and state, then same country, then first.""" + results = [r for r in results if isinstance(r, dict) + and "latitude" in r and "longitude" in r] + if not results: + return None + want_state = _norm_state(state) + want_country = _norm_country(country) + + def country_matches(r): + return bool(want_country) and want_country in ( + _norm_country(r.get("country_code")), _norm_country(r.get("country"))) + + def state_matches(r): + return bool(want_state) and _norm_state(r.get("admin1")) == want_state + + for test in (lambda r: country_matches(r) and state_matches(r), + country_matches, + state_matches): + for r in results: + if test(r): + return r + return results[0] + + +def geocode(city: str, state: Any = None, country: Any = None, + timeout: float = GEOCODE_TIMEOUT) -> Optional[Dict[str, Any]]: + """Look the city up on Open-Meteo. Raises on a network/HTTP failure.""" + import requests + + response = requests.get(GEOCODE_URL, params={ + "name": city, "count": GEOCODE_RESULT_COUNT, + "language": "en", "format": "json", + }, timeout=timeout) + response.raise_for_status() + best = pick_geocode_result(response.json().get("results") or [], state, country) + if best is None: + return None + return { + "lat": best["latitude"], + "lng": best["longitude"], + "timezone": best.get("timezone"), + } + + +class DeviceLocationResolver: + """Resolves the device location to a Pixlet location JSON string. + + Cached coordinates live in ``cache_manager`` (shared on disk by the + display and web processes) and in memory. A failed lookup is remembered + for ``FAILURE_RETRY_SECONDS`` so renders in the meantime fall straight + back to the app's own default. + """ + + def __init__(self, cache_manager: Any = None, + log: Optional[logging.Logger] = None, + geocoder: Callable[..., Optional[Dict[str, Any]]] = geocode, + clock: Callable[[], float] = time.time): + self.cache_manager = cache_manager + self.logger = log or logger + self._geocode = geocoder + self._clock = clock + self._coords: Dict[str, Dict[str, Any]] = {} + self._failed_at: Dict[str, float] = {} + + @staticmethod + def _cache_key(city: str, state: str, country: str) -> str: + # The key is a filename on disk: no spaces. + parts = (_norm(city), _norm_state(state), _norm_country(country)) + return ":".join((CACHE_KEY_PREFIX,) + tuple(p.replace(" ", "_") for p in parts)) + + def _cached(self, key: str) -> Optional[Dict[str, Any]]: + if key in self._coords: + return self._coords[key] + if self.cache_manager is None: + return None + try: + cached = self.cache_manager.get(key, max_age=COORDS_MAX_AGE) + except Exception: + self.logger.debug("Could not read cached device coordinates", exc_info=True) + return None + if isinstance(cached, dict) and "lat" in cached and "lng" in cached: + self._coords[key] = cached + return cached + return None + + def coordinates(self, device_location: Any) -> Optional[Dict[str, Any]]: + """``{"lat", "lng", "timezone"}`` for the device city, or None.""" + if not isinstance(device_location, dict): + return None + city = device_location.get("city") + if not isinstance(city, str) or not city.strip(): + return None + city = city.strip() + state = device_location.get("state") or "" + country = device_location.get("country") or "" + key = self._cache_key(city, state, country) + + cached = self._cached(key) + if cached is not None: + return cached + + failed_at = self._failed_at.get(key) + if failed_at is not None and self._clock() - failed_at < FAILURE_RETRY_SECONDS: + return None + + try: + coords = self._geocode(city, state, country) + except Exception as e: + self._failed_at[key] = self._clock() + self.logger.warning( + "Could not geocode device location %r: %s - Starlark apps " + "without a saved location use their own default", city, e) + return None + if not coords: + self._failed_at[key] = self._clock() + self.logger.warning( + "Geocoder found no match for device location %r, %r, %r - " + "Starlark apps without a saved location use their own default", + city, state, country) + return None + + self._failed_at.pop(key, None) + self._coords[key] = coords + if self.cache_manager is not None: + try: + self.cache_manager.set(key, coords, ttl=COORDS_MAX_AGE) + except Exception: + self.logger.debug("Could not cache device coordinates", exc_info=True) + return coords + + def location_json(self, device_location: Any, + device_timezone: Optional[str] = None, + saved: Optional[Dict[str, Any]] = None) -> Optional[str]: + """The device location as a Pixlet location string, or None. + + The timezone is the city's own (from the geocoder) when known, since + it belongs to the coordinates; the device timezone is the fallback. A + timezone the user typed into the app's location form (with no lat/lng) + is kept. + """ + coords = self.coordinates(device_location) + if coords is None: + return None + city = str(device_location.get("city", "")).strip() + state = str(device_location.get("state") or "").replace("_", " ").strip() + country = str(device_location.get("country") or "").strip() + timezone = ((saved or {}).get("timezone") or coords.get("timezone") + or device_timezone or "UTC") + return json.dumps({ + "lat": f"{float(coords['lat']):.4f}", + "lng": f"{float(coords['lng']):.4f}", + "locality": city, + "description": ", ".join(p for p in (city, state, country) if p), + "timezone": timezone, + }) + + +def apply_device_location(pixlet_config: Dict[str, Any], + schema: Optional[Dict[str, Any]], + resolver: DeviceLocationResolver, + device_config: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Fill a Starlark app's unset location fields with the device location. + + Returns a new dict. A usable saved location is left alone. An unset field + gets the device location, or -- when that can't be resolved -- is dropped, + so the app sees no value and uses its own default instead of failing to + decode an empty string. Never raises. + """ + config = dict(pixlet_config) + unset = [fid for fid in location_field_ids(schema) + if parse_location(config.get(fid)) is None] + if not unset: + return config + + device_config = device_config if isinstance(device_config, dict) else {} + for field_id in unset: + saved = config.get(field_id) + partial = None + if isinstance(saved, str) and saved.strip(): + try: + partial = json.loads(saved) + except (TypeError, ValueError): + partial = None + try: + value = resolver.location_json( + device_config.get("location"), device_config.get("timezone"), + partial if isinstance(partial, dict) else None) + except Exception: + logger.warning("Could not build device location for %s", field_id, exc_info=True) + value = None + if value is None: + config.pop(field_id, None) + else: + config[field_id] = value + return config diff --git a/test/test_device_location.py b/test/test_device_location.py new file mode 100644 index 00000000..8d6ea5f4 --- /dev/null +++ b/test/test_device_location.py @@ -0,0 +1,315 @@ +""" +Starlark app location fields default to the device's own location. + +The bug this pins: a user set Charlotte, North Carolina under General settings +(and in ledmatrix-weather), but a Tidbyt weather/radar app kept showing San +Francisco -- the ``DEFAULT_LOCATION`` its author hard-coded -- because the +app's own Location field was blank and nothing filled it. There was no San +Francisco anywhere in config.json to explain it. +""" + +import importlib +import json +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from src.device_location import ( + FAILURE_RETRY_SECONDS, + DeviceLocationResolver, + apply_device_location, + location_field_ids, + parse_location, + pick_geocode_result, +) + +CHARLOTTE = {"lat": 35.22709, "lng": -80.84313, "timezone": "America/New_York"} +DEVICE_CONFIG = { + "timezone": "America/Chicago", + "location": {"city": "Charlotte", "state": "North Carolina", "country": "US"}, +} +SCHEMA = {"version": "1", "schema": [ + {"typeOf": "location", "id": "location", "name": "Location"}, + {"typeOf": "text", "id": "api_key", "name": "API key"}, +]} +SAVED_BROOKLYN = json.dumps({"lat": "40.6782", "lng": "-73.9442", + "timezone": "America/New_York"}) + + +class FakeCache: + def __init__(self): + self.store = {} + + def get(self, key, max_age=300, memory_ttl=None): + return self.store.get(key) + + def set(self, key, data, ttl=None): + self.store[key] = data + + +class CountingGeocoder: + def __init__(self, result=CHARLOTTE, error=None): + self.result = result + self.error = error + self.calls = [] + + def __call__(self, city, state, country): + self.calls.append((city, state, country)) + if self.error: + raise self.error + return self.result + + +class Clock: + def __init__(self): + self.now = 1_000_000.0 + + def __call__(self): + return self.now + + +def resolver(geocoder=None, cache=None, clock=None): + return DeviceLocationResolver(cache if cache is not None else FakeCache(), + MagicMock(), geocoder or CountingGeocoder(), + clock or Clock()) + + +class TestApplyDeviceLocation: + def test_an_unset_location_renders_at_the_device_location(self): + out = apply_device_location({"api_key": "k"}, SCHEMA, resolver(), DEVICE_CONFIG) + loc = json.loads(out["location"]) + assert (loc["lat"], loc["lng"]) == ("35.2271", "-80.8431") + assert loc["locality"] == "Charlotte" + assert out["api_key"] == "k" + + @pytest.mark.parametrize("blank", ["", " ", None, "{}", "not json", + json.dumps({"timezone": "America/Denver"})]) + def test_a_blank_or_lat_lng_less_value_counts_as_unset(self, blank): + out = apply_device_location({"location": blank}, SCHEMA, resolver(), DEVICE_CONFIG) + assert json.loads(out["location"])["lat"] == "35.2271" + + def test_a_saved_location_wins(self): + geocoder = CountingGeocoder() + out = apply_device_location({"location": SAVED_BROOKLYN}, SCHEMA, + resolver(geocoder), DEVICE_CONFIG) + assert out["location"] == SAVED_BROOKLYN + assert geocoder.calls == [], "nothing to resolve, so no network" + + def test_the_input_is_not_mutated(self): + config = {"location": ""} + apply_device_location(config, SCHEMA, resolver(), DEVICE_CONFIG) + assert config == {"location": ""} + + def test_a_timezone_typed_without_coordinates_is_kept(self): + partial = json.dumps({"timezone": "America/Denver"}) + out = apply_device_location({"location": partial}, SCHEMA, resolver(), DEVICE_CONFIG) + assert json.loads(out["location"])["timezone"] == "America/Denver" + + def test_the_timezone_is_the_citys_own(self): + """The device timezone (Chicago here) is only a fallback.""" + out = apply_device_location({}, SCHEMA, resolver(), DEVICE_CONFIG) + assert json.loads(out["location"])["timezone"] == "America/New_York" + + def test_the_device_timezone_fills_in_when_the_geocoder_has_none(self): + geocoder = CountingGeocoder({"lat": 35.2, "lng": -80.8, "timezone": None}) + out = apply_device_location({}, SCHEMA, resolver(geocoder), DEVICE_CONFIG) + assert json.loads(out["location"])["timezone"] == "America/Chicago" + + def test_geocode_failure_falls_back_to_the_apps_own_default(self): + """Dropped rather than passed blank: an app decoding "" would crash.""" + failing = resolver(CountingGeocoder(error=OSError("network down"))) + out = apply_device_location({"location": "", "api_key": "k"}, SCHEMA, + failing, DEVICE_CONFIG) + assert "location" not in out + assert out["api_key"] == "k" + + def test_no_match_falls_back_too(self): + out = apply_device_location({}, SCHEMA, resolver(CountingGeocoder(result=None)), + DEVICE_CONFIG) + assert "location" not in out + + @pytest.mark.parametrize("device", [{}, None, {"location": {}}, + {"location": {"city": " "}}, + {"location": "Charlotte"}]) + def test_no_device_location_falls_back(self, device): + geocoder = CountingGeocoder() + out = apply_device_location({}, SCHEMA, resolver(geocoder), device) + assert "location" not in out + assert geocoder.calls == [] + + def test_an_app_without_a_location_field_is_untouched(self): + schema = {"schema": [{"typeOf": "text", "id": "location"}]} + out = apply_device_location({"location": ""}, schema, resolver(), DEVICE_CONFIG) + assert out == {"location": ""} + + def test_an_app_without_a_schema_is_untouched(self): + assert apply_device_location({"a": 1}, None, resolver(), DEVICE_CONFIG) == {"a": 1} + + +class TestGeocodingIsCached: + def test_the_city_is_geocoded_once(self): + geocoder = CountingGeocoder() + r = resolver(geocoder) + for _ in range(3): + apply_device_location({}, SCHEMA, r, DEVICE_CONFIG) + assert len(geocoder.calls) == 1 + + def test_the_cache_survives_a_restart(self): + cache = FakeCache() + resolver(CountingGeocoder(), cache).coordinates(DEVICE_CONFIG["location"]) + geocoder = CountingGeocoder() + coords = resolver(geocoder, cache).coordinates(DEVICE_CONFIG["location"]) + assert coords["lat"] == CHARLOTTE["lat"] + assert geocoder.calls == [] + + def test_a_new_device_city_is_looked_up(self): + geocoder = CountingGeocoder() + r = resolver(geocoder) + r.coordinates({"city": "Charlotte", "state": "NC", "country": "US"}) + r.coordinates({"city": "Tampa", "state": "Florida", "country": "US"}) + assert [c[0] for c in geocoder.calls] == ["Charlotte", "Tampa"] + + def test_state_spellings_share_one_cache_entry(self): + """"North_Carolina", "north carolina" and "NC" are the same place.""" + geocoder = CountingGeocoder() + r = resolver(geocoder) + for state in ("North Carolina", "North_Carolina", "NC"): + r.coordinates({"city": "Charlotte", "state": state, "country": "US"}) + assert len(geocoder.calls) == 1 + + def test_a_failure_is_not_retried_on_every_render(self): + clock = Clock() + geocoder = CountingGeocoder(error=OSError("down")) + r = resolver(geocoder, clock=clock) + for _ in range(5): + assert r.coordinates(DEVICE_CONFIG["location"]) is None + assert len(geocoder.calls) == 1 + + clock.now += FAILURE_RETRY_SECONDS + 1 + geocoder.error = None + assert r.coordinates(DEVICE_CONFIG["location"])["lat"] == CHARLOTTE["lat"] + assert len(geocoder.calls) == 2 + + def test_a_broken_cache_does_not_stop_the_lookup(self): + cache = MagicMock() + cache.get.side_effect = OSError("disk") + cache.set.side_effect = OSError("disk") + assert resolver(cache=cache).coordinates(DEVICE_CONFIG["location"]) is not None + + +class TestPickGeocodeResult: + RESULTS = [ + {"latitude": 42.56, "longitude": -84.84, "country_code": "US", "admin1": "Michigan"}, + {"latitude": 35.23, "longitude": -80.84, "country_code": "US", "admin1": "North Carolina"}, + {"latitude": 18.34, "longitude": -64.93, "country_code": "VI", "admin1": "St Thomas"}, + ] + + @pytest.mark.parametrize("state", ["North Carolina", "north_carolina", "NC", "nc"]) + def test_the_configured_state_wins_over_the_first_hit(self, state): + assert pick_geocode_result(self.RESULTS, state, "US")["admin1"] == "North Carolina" + + @pytest.mark.parametrize("country", ["US", "us", "USA", "United States"]) + def test_country_spellings_match(self, country): + best = pick_geocode_result(self.RESULTS, "", country) + assert best["country_code"] == "US" + + def test_an_unknown_state_falls_back_to_the_country(self): + assert pick_geocode_result(self.RESULTS, "Ontario", "US")["admin1"] == "Michigan" + + def test_with_nothing_to_match_the_first_hit_is_used(self): + assert pick_geocode_result(self.RESULTS, "", "")["admin1"] == "Michigan" + + def test_no_results(self): + assert pick_geocode_result([], "NC", "US") is None + + +def test_location_field_ids_reads_both_schema_shapes(): + assert location_field_ids({"fields": [{"typeOf": "location", "id": "a"}]}) == ["a"] + assert location_field_ids({"schema": [{"type": "Location", "id": "b"}, + {"typeOf": "location_based", "id": "c"}]}) == ["b"] + + +def test_parse_location_accepts_a_dict(): + assert parse_location({"lat": 1, "lng": 2}) == {"lat": 1, "lng": 2} + assert parse_location({"lat": "x", "lng": 2}) is None + + +# --------------------------------------------------------------------------- +# The display plugin's render path +# --------------------------------------------------------------------------- + +PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps" + + +@pytest.fixture(scope="module") +def manager_module(): + if not PLUGIN_DIR.exists(): + pytest.skip("starlark-apps plugin is not checked out") + sys.path.insert(0, str(PLUGIN_DIR)) + # See test_starlark_display_contract.py: fcntl is POSIX-only and unused here. + injected_fcntl = "fcntl" not in sys.modules + if injected_fcntl: + stub = types.ModuleType("fcntl") + stub.LOCK_EX, stub.LOCK_UN = 2, 8 + stub.flock = lambda *a, **kw: None + sys.modules["fcntl"] = stub + try: + spec = importlib.util.spec_from_file_location( + "starlark_manager_location_test", PLUGIN_DIR / "manager.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + except Exception as e: # noqa: BLE001 - optional deps may be absent + pytest.skip(f"starlark-apps manager is not importable here: {e}") + finally: + sys.path.remove(str(PLUGIN_DIR)) + if injected_fcntl: + sys.modules.pop("fcntl", None) + + +def _render(manager_module, tmp_path, app_config, geocoder): + """Run _render_app and return the config Pixlet was handed.""" + plugin_cls = manager_module.StarlarkAppsPlugin + plugin = plugin_cls.__new__(plugin_cls) + plugin.logger = MagicMock() + plugin.config = {} + plugin.calculated_magnify = 1 + plugin.pixlet = MagicMock() + plugin.pixlet.render.return_value = (True, None) + plugin._load_frames_from_cache = MagicMock(return_value=True) + plugin.device_location = resolver(geocoder) + plugin.global_config = DEVICE_CONFIG + + app_cls = manager_module.StarlarkApp + app = app_cls.__new__(app_cls) + app.app_id = "weather" + app.config = dict(app_config) + app.schema = SCHEMA + app.star_file = tmp_path / "weather.star" + app.cache_file = tmp_path / "cached_render.webp" + app.last_render_time = 0 + + assert plugin._render_app(app, force=True) is True + return plugin.pixlet.render.call_args.kwargs["config"], app + + +class TestTheDisplayPluginRender: + def test_an_unset_location_is_rendered_at_the_device_location(self, manager_module, tmp_path): + config, app = _render(manager_module, tmp_path, {"render_interval": 300}, + CountingGeocoder()) + assert json.loads(config["location"])["lat"] == "35.2271" + assert "render_interval" not in config + assert "location" not in app.config, "never written back to the app's config" + + def test_a_saved_location_wins(self, manager_module, tmp_path): + config, _ = _render(manager_module, tmp_path, {"location": SAVED_BROOKLYN}, + CountingGeocoder()) + assert config["location"] == SAVED_BROOKLYN + + def test_geocode_failure_still_renders_with_the_apps_default(self, manager_module, tmp_path): + config, _ = _render(manager_module, tmp_path, {"location": ""}, + CountingGeocoder(error=OSError("down"))) + assert "location" not in config diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 275859f2..ae2ab55c 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -969,3 +969,59 @@ class TestPixletEditorHostDefaultsButDoesNotOverride: def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path): env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1') assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1' + + +class TestStandaloneRenderUsesTheDeviceLocation: + """The web-service render (plugin not loaded) fills a blank Location field + the same way the display plugin does -- see test/test_device_location.py. + """ + + SCHEMA = {"schema": [{"typeOf": "location", "id": "location"}]} + + @pytest.fixture + def app_dir(self, tmp_path, monkeypatch): + from web_interface.blueprints import api_v3 as module + apps_dir = tmp_path / "starlark-apps" + app_dir = apps_dir / "weather" + app_dir.mkdir(parents=True) + (app_dir / "weather.star").write_text("# app") + (app_dir / "schema.json").write_text(json.dumps(self.SCHEMA)) + monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir) + monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json') + (apps_dir / 'manifest.json').write_text(json.dumps( + {'apps': {'weather': {'star_file': 'weather.star'}}})) + config_manager = MagicMock() + config_manager.load_config.return_value = { + 'timezone': 'America/New_York', + 'location': {'city': 'Charlotte', 'state': 'North Carolina', 'country': 'US'}, + } + monkeypatch.setattr(module.api_v3, 'config_manager', config_manager, raising=False) + monkeypatch.setattr(module, '_find_pixlet_binary', lambda _p=None: '/usr/bin/pixlet') + from src.device_location import DeviceLocationResolver + geocoder = MagicMock(return_value={'lat': 35.22709, 'lng': -80.84313, + 'timezone': 'America/New_York'}) + monkeypatch.setattr(module, '_starlark_device_location', + DeviceLocationResolver(None, MagicMock(), geocoder)) + return app_dir + + def _render_args(self, app_dir): + from web_interface.blueprints import api_v3 as module + + def fake_run(cmd, **kwargs): + (app_dir / 'cached_render.webp').write_bytes(b'webp') + return MagicMock(returncode=0, stderr='') + + with patch.object(module.subprocess, 'run', side_effect=fake_run) as run: + ok, status, err = module._standalone_render_starlark_app('weather') + assert ok, err + return [a for a in run.call_args.args[0] if a.startswith('location=')] + + def test_a_blank_location_renders_at_the_device_city(self, app_dir): + (app_dir / 'config.json').write_text(json.dumps({'location': ''})) + [arg] = self._render_args(app_dir) + assert json.loads(arg[len('location='):])['lat'] == '35.2271' + + def test_a_saved_location_wins(self, app_dir): + saved = json.dumps({'lat': '40.6782', 'lng': '-73.9442'}) + (app_dir / 'config.json').write_text(json.dumps({'location': saved})) + assert self._render_args(app_dir) == [f'location={saved}'] diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index aa7f2d03..6d26a980 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -49,6 +49,7 @@ from src.web_interface.validators import ( from src.error_aggregator import get_error_aggregator from src.common.permission_utils import install_requirements_file from src.common.path_safety import resolve_under +from src.device_location import DeviceLocationResolver, apply_device_location _SUDO = shutil.which('sudo') _JOURNALCTL = shutil.which('journalctl') _GIT = shutil.which('git') @@ -1703,6 +1704,32 @@ def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[s except OSError as e: logger.warning("Path validation error for app_id %r: %s", app_id, e) return None, "Invalid app_id" +_starlark_device_location: Optional[DeviceLocationResolver] = None + + +def _get_starlark_device_location() -> DeviceLocationResolver: + """One resolver per process, so a geocode failure's backoff is shared.""" + global _starlark_device_location + if _starlark_device_location is None: + _starlark_device_location = DeviceLocationResolver( + getattr(api_v3, 'cache_manager', None) or _ensure_cache_manager(), logger) + return _starlark_device_location + + +def _read_starlark_schema(app_dir: Path) -> Optional[Dict[str, Any]]: + """An installed app's schema.json, or None if it has none or it's unreadable.""" + schema_file = app_dir / 'schema.json' + if not schema_file.exists(): + return None + try: + with open(schema_file) as f: + schema = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Could not read schema.json at %s: %s", schema_file, e) + return None + return schema if isinstance(schema, dict) else None + + def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: """Render a Starlark app via pixlet directly (no plugin required). @@ -1774,6 +1801,9 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st INTERNAL_KEYS = {'render_interval', 'display_duration'} pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS} + pixlet_config = apply_device_location( + pixlet_config, _read_starlark_schema(app_dir), + _get_starlark_device_location(), full_config) output_path = str(app_dir / 'cached_render.webp') cmd = [pixlet_path, 'render', str(star_file)] diff --git a/web_interface/templates/v3/partials/starlark_config.html b/web_interface/templates/v3/partials/starlark_config.html index bd7af5f6..e099ce1a 100644 --- a/web_interface/templates/v3/partials/starlark_config.html +++ b/web_interface/templates/v3/partials/starlark_config.html @@ -221,6 +221,7 @@ data-starlark-location-key="timezone"> +

Leave latitude and longitude blank to use this device's location (City / State / Country in General settings). If no city is set there, or it can't be looked up, the app uses its own default.

{% if field_desc %}

{{ field_desc }}

{% endif %} @@ -421,9 +422,10 @@ function saveStarlarkConfig(appId) { var locKey = sub.getAttribute('data-starlark-location-key'); if (sub.value) loc[locKey] = sub.value; }); - if (Object.keys(loc).length > 0) { - config[fieldId] = JSON.stringify(loc); - } + // Blank still has to be sent: the save merges into the stored config, + // so leaving the key out kept the old location with no way to clear + // it back to the device's. + config[fieldId] = Object.keys(loc).length > 0 ? JSON.stringify(loc) : ''; }); fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/config', {