diff --git a/plugins.json b/plugins.json index c54ca1a..563d077 100644 --- a/plugins.json +++ b/plugins.json @@ -1,7 +1,34 @@ { "version": "1.0.0", - "last_updated": "2026-07-08", + "last_updated": "2026-07-10", "plugins": [ + { + "id": "cricket-scoreboard", + "name": "Cricket Scoreboard", + "description": "Live, recent, and upcoming cricket matches across international tours (Test/ODI/T20I) and major domestic T20 leagues including the IPL, Big Bash League, The Hundred, PSL, CPL, SA20 and more. Shows runs/wickets/overs, run rates, targets, and match results.", + "author": "ChuckBuilds", + "category": "sports", + "tags": [ + "cricket", + "ipl", + "big-bash", + "t20", + "test-cricket", + "odi", + "sports", + "scoreboard", + "live-scores" + ], + "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", + "branch": "main", + "plugin_path": "plugins/cricket-scoreboard", + "stars": 0, + "downloads": 0, + "last_updated": "2026-07-10", + "verified": true, + "screenshot": "", + "latest_version": "1.0.2" + }, { "id": "7-segment-clock", "name": "7-Segment Clock", diff --git a/plugins/cricket-scoreboard/.gitignore b/plugins/cricket-scoreboard/.gitignore new file mode 100644 index 0000000..979acec --- /dev/null +++ b/plugins/cricket-scoreboard/.gitignore @@ -0,0 +1,146 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypcache__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# OS specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Project specific +*.log +config.json +*.pem diff --git a/plugins/cricket-scoreboard/LICENSE b/plugins/cricket-scoreboard/LICENSE new file mode 100644 index 0000000..e653a0c --- /dev/null +++ b/plugins/cricket-scoreboard/LICENSE @@ -0,0 +1,17 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2025 LEDMatrix Team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/plugins/cricket-scoreboard/README.md b/plugins/cricket-scoreboard/README.md new file mode 100644 index 0000000..6656320 --- /dev/null +++ b/plugins/cricket-scoreboard/README.md @@ -0,0 +1,109 @@ +# Cricket Scoreboard + +Live, recent, and upcoming cricket for the LEDMatrix display — covering both +**international** matches (Test / ODI / T20I) and the **major domestic T20 +leagues** (IPL, Big Bash, The Hundred, PSL, CPL, SA20, ILT20, MLC, and more). + +Data comes from ESPN's public cricket API (no API key required). + +## Display modes + +| Mode | Shows | +|------|-------| +| `cricket_live` | In-progress matches. Limited-overs matches show `runs/wickets (overs/max ov)`, current run rate, and — when a side is chasing — the target, runs still needed, and required run rate. Test matches show the day + session (Stumps / Lunch / Tea …) and both innings, with no clock. | +| `cricket_recent` | Completed matches: final scores + ESPN's result summary (e.g. `"RCB won by 5 wkts (12b rem)"`). | +| `cricket_upcoming` | Scheduled matches: date/time, teams, venue, and a format badge. | + +Live matches take priority in the rotation when `live_priority` is on. + +## How series discovery works + +Unlike most sports, cricket has **no single stable league id** on ESPN — it is +organised as dozens of concurrent, per-tour / per-season numeric *series* ids +(e.g. a given season of the IPL might be `8048`, Major League Cricket `21266`, +the Vitality Blast `8053`). Those ids change every season. + +So instead of hardcoding ids, the plugin **discovers them periodically**: + +1. It reads a curated seed list, [`competitions.json`](competitions.json), that + maps human competition keys (`ipl`, `bbl`, `the-hundred`, …) to stable + **search terms** (competition *names* are far more stable than their ids). +2. Every `series_discovery_interval` seconds (default 24h) it queries ESPN's + cricket header endpoint, lists every active series, and resolves them: + - **domestic** competitions are matched by name against your + `favorite_competitions` search terms; + - **international** series are matched by your `favorite_teams` national-team + names appearing in the series/event names (e.g. *"India tour of England + 2026"*). +3. Each resolved numeric id's `/scoreboard` is then fetched and parsed. + +Resolved ids are cached (via the host `cache_manager`) so discovery is cheap. + +## Overs math (base-6) + +Cricket overs are **base-6 in the fractional part**: `18.4` overs means 18 +completed overs **plus 4 balls** = `18 + 4/6 = 18.667` decimal overs, *not* +18.4. The plugin converts `overs → decimal overs` before any run-rate division: + +- current run rate = `runs / decimal_overs` +- required run rate = `runs_needed / (balls_remaining / 6)` + +This conversion is unit-tested (`test_cricket_plugin.py::TestOversMath`). + +## Configuration + +Configured under the `cricket-scoreboard` key in `config/config.json`. See +[`config_schema.json`](config_schema.json) for the full schema; key fields: + +| Key | Default | Purpose | +|-----|---------|---------| +| `enabled` | `false` | Master on/off | +| `favorite_teams` | `[]` | National teams (name or abbr, e.g. `"India"`, `"IND"`) whose international series are followed | +| `favorite_competitions` | `["international","ipl","bbl"]` | Domestic competition keys from `competitions.json` (`international` follows Test/ODI/T20I tours for your favorite teams) | +| `exclude_teams` | `[]` | Teams to always hide (spoiler protection) | +| `show_favorite_teams_only` | `false` | Restrict *international* matches to those featuring a favorite team | +| `live_game_duration` / `recent_game_duration` / `upcoming_game_duration` | 20 / 15 / 15 | Per-match on-screen seconds | +| `non_favorite_live_game_duration` | `0` | Shorter turn for live matches without a favorite (0 = same as `live_game_duration`) | +| `update_interval_seconds` / `live_update_interval` | 3600 / 30 | Data refresh cadence | +| `series_discovery_interval` | `86400` | How often numeric series ids are re-resolved | +| `recent_games_to_show` / `upcoming_games_to_show` | 5 / 5 | Match counts per mode | +| `live_priority` | `true` | Live matches interrupt the normal rotation | +| `display_modes` | all on | Toggle live / recent / upcoming | +| `dynamic_duration`, `mode_durations` | off / null | Auto-size or cap each mode's total time | +| `celebration_enabled`, `celebration_duration` | true / 8 | Win-celebration takeover | +| `background_service` | enabled | Worker/timeout/retry tuning | +| `customization` | — | Fonts + colors for score / overs / team / status / detail text | + +## Logos and flags + +- **Franchise** teams (IPL/BBL/etc.) auto-download their logo from ESPN + (`team.logos[0].href`) into `assets/logos/` on first sight, with a + text-abbreviation placeholder on failure — the same pattern the other sports + scoreboards use. +- **National** teams use bundled flag PNGs in + [`assets/flags/`](assets/flags/) (keyed by both abbreviation and lowercase + name), because national-team abbreviations can collide with franchise codes. + +> **Note:** the bundled flags are **simple generated placeholders** (solid +> national colors + abbreviation) so the wiring is complete and functional out +> of the box. Replace them with real 48×48 flag art (same filenames) for a nicer +> display. + +## v1 limitations + +- **No per-ball / per-player detail** (current striker, bowler figures, economy + rate). ESPN's cricket scoreboard response carries no `rosters`/`lineups`; that + would require a separate boxscore endpoint and is a possible fast-follow. +- **The Hundred** counts balls (100) rather than overs; run rates for it are + best-effort. +- Flag art is placeholder (see above). + +## Testing + +```bash +python -m unittest test_cricket_plugin # needs Pillow + requests +``` + +Covers the base-6 overs conversion, run-rate math, format normalization, all +four renderer branches at every matrix size, and a manager update/display +smoke test driven by the mocked responses in [`test/harness.json`](test/harness.json). diff --git a/plugins/cricket-scoreboard/assets/flags/AFG.png b/plugins/cricket-scoreboard/assets/flags/AFG.png new file mode 100644 index 0000000..18d83f3 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/AFG.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/AUS.png b/plugins/cricket-scoreboard/assets/flags/AUS.png new file mode 100644 index 0000000..724eade Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/AUS.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/BAN.png b/plugins/cricket-scoreboard/assets/flags/BAN.png new file mode 100644 index 0000000..f22fade Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/BAN.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/ENG.png b/plugins/cricket-scoreboard/assets/flags/ENG.png new file mode 100644 index 0000000..5b9308a Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/ENG.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/IND.png b/plugins/cricket-scoreboard/assets/flags/IND.png new file mode 100644 index 0000000..cec28f0 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/IND.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/IRE.png b/plugins/cricket-scoreboard/assets/flags/IRE.png new file mode 100644 index 0000000..f86c6b5 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/IRE.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/NZ.png b/plugins/cricket-scoreboard/assets/flags/NZ.png new file mode 100644 index 0000000..2ebcbfd Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/NZ.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/PAK.png b/plugins/cricket-scoreboard/assets/flags/PAK.png new file mode 100644 index 0000000..8bd196b Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/PAK.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/RSA.png b/plugins/cricket-scoreboard/assets/flags/RSA.png new file mode 100644 index 0000000..08dd7f1 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/RSA.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/SA.png b/plugins/cricket-scoreboard/assets/flags/SA.png new file mode 100644 index 0000000..1975444 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/SA.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/SL.png b/plugins/cricket-scoreboard/assets/flags/SL.png new file mode 100644 index 0000000..034a6d4 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/SL.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/WI.png b/plugins/cricket-scoreboard/assets/flags/WI.png new file mode 100644 index 0000000..34ed35b Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/WI.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/ZIM.png b/plugins/cricket-scoreboard/assets/flags/ZIM.png new file mode 100644 index 0000000..fab010a Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/ZIM.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/afghanistan.png b/plugins/cricket-scoreboard/assets/flags/afghanistan.png new file mode 100644 index 0000000..18d83f3 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/afghanistan.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/australia.png b/plugins/cricket-scoreboard/assets/flags/australia.png new file mode 100644 index 0000000..724eade Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/australia.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/bangladesh.png b/plugins/cricket-scoreboard/assets/flags/bangladesh.png new file mode 100644 index 0000000..f22fade Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/bangladesh.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/england.png b/plugins/cricket-scoreboard/assets/flags/england.png new file mode 100644 index 0000000..5b9308a Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/england.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/india.png b/plugins/cricket-scoreboard/assets/flags/india.png new file mode 100644 index 0000000..cec28f0 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/india.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/ireland.png b/plugins/cricket-scoreboard/assets/flags/ireland.png new file mode 100644 index 0000000..f86c6b5 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/ireland.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/new_zealand.png b/plugins/cricket-scoreboard/assets/flags/new_zealand.png new file mode 100644 index 0000000..2ebcbfd Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/new_zealand.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/pakistan.png b/plugins/cricket-scoreboard/assets/flags/pakistan.png new file mode 100644 index 0000000..8bd196b Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/pakistan.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/south_africa.png b/plugins/cricket-scoreboard/assets/flags/south_africa.png new file mode 100644 index 0000000..08dd7f1 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/south_africa.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/south_africa2.png b/plugins/cricket-scoreboard/assets/flags/south_africa2.png new file mode 100644 index 0000000..1975444 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/south_africa2.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/sri_lanka.png b/plugins/cricket-scoreboard/assets/flags/sri_lanka.png new file mode 100644 index 0000000..034a6d4 Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/sri_lanka.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/west_indies.png b/plugins/cricket-scoreboard/assets/flags/west_indies.png new file mode 100644 index 0000000..34ed35b Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/west_indies.png differ diff --git a/plugins/cricket-scoreboard/assets/flags/zimbabwe.png b/plugins/cricket-scoreboard/assets/flags/zimbabwe.png new file mode 100644 index 0000000..fab010a Binary files /dev/null and b/plugins/cricket-scoreboard/assets/flags/zimbabwe.png differ diff --git a/plugins/cricket-scoreboard/assets/fonts/4x6-font.ttf b/plugins/cricket-scoreboard/assets/fonts/4x6-font.ttf new file mode 100644 index 0000000..e8febca Binary files /dev/null and b/plugins/cricket-scoreboard/assets/fonts/4x6-font.ttf differ diff --git a/plugins/cricket-scoreboard/assets/fonts/5by7.regular.ttf b/plugins/cricket-scoreboard/assets/fonts/5by7.regular.ttf new file mode 100644 index 0000000..3d9c65d Binary files /dev/null and b/plugins/cricket-scoreboard/assets/fonts/5by7.regular.ttf differ diff --git a/plugins/cricket-scoreboard/assets/fonts/PressStart2P-Regular.ttf b/plugins/cricket-scoreboard/assets/fonts/PressStart2P-Regular.ttf new file mode 100644 index 0000000..2442aff Binary files /dev/null and b/plugins/cricket-scoreboard/assets/fonts/PressStart2P-Regular.ttf differ diff --git a/plugins/cricket-scoreboard/competitions.json b/plugins/cricket-scoreboard/competitions.json new file mode 100644 index 0000000..f2841f9 --- /dev/null +++ b/plugins/cricket-scoreboard/competitions.json @@ -0,0 +1,63 @@ +[ + { + "key": "international", + "name": "International (Test / ODI / T20I)", + "search_terms": null, + "description": "International tours and ICC events (Test, ODI, T20I). Matched by favorite national teams rather than a competition name." + }, + { + "key": "ipl", + "name": "Indian Premier League", + "search_terms": ["Indian Premier League", "IPL"] + }, + { + "key": "bbl", + "name": "Big Bash League", + "search_terms": ["Big Bash League", "KFC Big Bash", "BBL"] + }, + { + "key": "the-hundred", + "name": "The Hundred", + "search_terms": ["The Hundred"] + }, + { + "key": "psl", + "name": "Pakistan Super League", + "search_terms": ["Pakistan Super League", "PSL"] + }, + { + "key": "cpl", + "name": "Caribbean Premier League", + "search_terms": ["Caribbean Premier League", "CPL"] + }, + { + "key": "t20-blast", + "name": "T20 Blast (England)", + "search_terms": ["Twenty20 Cup", "Vitality Blast", "T20 Blast", "Blast"] + }, + { + "key": "sa20", + "name": "SA20 (South Africa)", + "search_terms": ["SA20", "SA 20"] + }, + { + "key": "ilt20", + "name": "International League T20 (UAE)", + "search_terms": ["International League T20", "ILT20", "DP World ILT20"] + }, + { + "key": "mlc", + "name": "Major League Cricket (USA)", + "search_terms": ["Major League Cricket", "MLC"] + }, + { + "key": "wbbl", + "name": "Women's Big Bash League", + "search_terms": ["Women's Big Bash League", "WBBL"] + }, + { + "key": "wpl", + "name": "Women's Premier League", + "search_terms": ["Women's Premier League", "WPL"] + } +] diff --git a/plugins/cricket-scoreboard/config_schema.json b/plugins/cricket-scoreboard/config_schema.json new file mode 100644 index 0000000..224166f --- /dev/null +++ b/plugins/cricket-scoreboard/config_schema.json @@ -0,0 +1,493 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Cricket Scoreboard Plugin Configuration", + "description": "Configuration schema for the Cricket Scoreboard plugin. Covers international matches (Test/ODI/T20I) and major domestic T20 leagues (IPL, Big Bash, The Hundred, etc.).", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable or disable the cricket scoreboard plugin" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "National team name or abbreviation (e.g. 'India', 'IND', 'Australia', 'England')" + }, + "uniqueItems": true, + "maxItems": 30, + "default": [], + "description": "Favorite national teams. Any international series (tour, World Cup, bilateral) featuring one of these teams is discovered and shown. Matches on team name appearing in the series/match name." + }, + "favorite_competitions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "international", + "ipl", + "bbl", + "the-hundred", + "psl", + "cpl", + "t20-blast", + "sa20", + "ilt20", + "mlc", + "wbbl", + "wpl" + ] + }, + "uniqueItems": true, + "default": ["international", "ipl", "bbl"], + "description": "Domestic competitions (keys from competitions.json) to follow. Include 'international' to follow Test/ODI/T20I tours for your favorite_teams." + }, + "exclude_teams": { + "type": "array", + "items": { + "type": "string", + "description": "Team name or abbreviation to always hide (spoiler protection)" + }, + "uniqueItems": true, + "maxItems": 30, + "default": [], + "description": "Teams to always hide from live rotation and recent/final scores (spoiler protection). Takes precedence over favorite_teams." + }, + "show_favorite_teams_only": { + "type": "boolean", + "default": false, + "description": "Only show matches involving a favorite national team. Domestic-league matches are always governed by favorite_competitions." + }, + "display_duration": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Duration in seconds to display each match" + }, + "live_game_duration": { + "type": "integer", + "default": 20, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live match before rotating to the next" + }, + "non_favorite_live_game_duration": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 120, + "description": "Duration in seconds for live matches that do NOT involve a favorite team. 0 (default) = use live_game_duration for every live match." + }, + "recent_game_duration": { + "type": "integer", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Duration in seconds to display each recent match" + }, + "upcoming_game_duration": { + "type": "integer", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Duration in seconds to display each upcoming match" + }, + "update_interval_seconds": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new match data (seconds)" + }, + "live_update_interval": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 300, + "description": "Update interval for live matches (seconds)" + }, + "recent_update_interval": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for recent matches (seconds)" + }, + "upcoming_update_interval": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for upcoming matches (seconds)" + }, + "series_discovery_interval": { + "type": "integer", + "default": 86400, + "minimum": 3600, + "maximum": 604800, + "description": "How often to re-resolve numeric ESPN series IDs from the header endpoint (seconds). Series IDs change per tour/season, so they are re-discovered periodically rather than hardcoded. Default 24h." + }, + "recent_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "Maximum number of recent (completed) matches to show" + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "Maximum number of upcoming (scheduled) matches to show" + }, + "live_priority": { + "type": "boolean", + "default": true, + "description": "Give live matches priority over other modes. Live matches interrupt normal rotation and are displayed immediately when available." + }, + "show_records": { + "type": "boolean", + "default": false, + "description": "Show team records (played-won) when available" + }, + "show_venue": { + "type": "boolean", + "default": true, + "description": "Show the venue/ground on upcoming match cards" + }, + "celebration_enabled": { + "type": "boolean", + "default": true, + "description": "Show a celebratory takeover screen when a favorite team wins a live match" + }, + "celebration_duration": { + "type": "integer", + "default": 8, + "minimum": 3, + "maximum": 30, + "description": "How long the win celebration stays on screen (seconds)" + }, + "dynamic_duration": { + "type": "object", + "title": "Dynamic Duration Settings", + "description": "Automatically size each mode's total on-screen time based on how many matches are available.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration (total_matches x per_match_duration)" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for a mode, even if few matches are available" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "default": 300, + "description": "Maximum total duration in seconds for a mode" + } + }, + "additionalProperties": false + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If null, uses dynamic calculation (total_matches x per_match_duration).", + "properties": { + "live_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (dynamic)." + }, + "recent_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (dynamic)." + }, + "upcoming_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (dynamic)." + } + }, + "additionalProperties": false + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which match types to show", + "properties": { + "live": { + "type": "boolean", + "default": true, + "description": "Show live matches" + }, + "recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed matches" + }, + "upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming matches" + } + }, + "additionalProperties": false + }, + "background_service": { + "type": "object", + "title": "Background Service", + "description": "Settings for background data fetching", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable background service for data fetching" + }, + "max_workers": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Maximum number of worker threads" + }, + "request_timeout": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 120, + "description": "Request timeout in seconds" + }, + "max_retries": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Maximum number of retries for failed requests" + }, + "priority": { + "type": "integer", + "default": 2, + "minimum": 1, + "maximum": 5, + "description": "Background service priority" + } + }, + "additionalProperties": false + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Match Score", + "description": "Font settings for the runs/wickets score display", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "additionalProperties": false + }, + "period_text": { + "type": "object", + "title": "Overs / Session", + "description": "Font settings for overs, session and day text", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "additionalProperties": false + }, + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g. result summary, 'Stumps', format badge)", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Details (Run Rate / Target)", + "description": "Font settings for run rate, required run rate and target text", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "additionalProperties": false + }, + "colors": { + "type": "object", + "title": "Colors", + "description": "RGB hex colors for text elements", + "properties": { + "score_color": { + "type": "string", + "pattern": "^#?[0-9A-Fa-f]{6}$", + "default": "#FFFFFF", + "description": "Color for the runs/wickets score" + }, + "batting_color": { + "type": "string", + "pattern": "^#?[0-9A-Fa-f]{6}$", + "default": "#00FF66", + "description": "Highlight color for the team currently batting" + }, + "detail_color": { + "type": "string", + "pattern": "^#?[0-9A-Fa-f]{6}$", + "default": "#FFD200", + "description": "Color for run rate / target detail text" + }, + "status_color": { + "type": "string", + "pattern": "^#?[0-9A-Fa-f]{6}$", + "default": "#AAAAAA", + "description": "Color for status / result text" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "enabled" + ], + "x-propertyOrder": [ + "enabled", + "favorite_teams", + "favorite_competitions", + "exclude_teams", + "show_favorite_teams_only", + "display_duration", + "live_game_duration", + "non_favorite_live_game_duration", + "recent_game_duration", + "upcoming_game_duration", + "update_interval_seconds", + "live_update_interval", + "recent_update_interval", + "upcoming_update_interval", + "series_discovery_interval", + "recent_games_to_show", + "upcoming_games_to_show", + "live_priority", + "show_records", + "show_venue", + "celebration_enabled", + "celebration_duration", + "dynamic_duration", + "mode_durations", + "display_modes", + "background_service", + "customization" + ] +} diff --git a/plugins/cricket-scoreboard/cricket_data_fetcher.py b/plugins/cricket-scoreboard/cricket_data_fetcher.py new file mode 100644 index 0000000..924c8f7 --- /dev/null +++ b/plugins/cricket-scoreboard/cricket_data_fetcher.py @@ -0,0 +1,483 @@ +""" +Cricket data fetcher for the Cricket Scoreboard plugin. + +Cricket on ESPN has NO single stable league id (unlike, say, AFL's `afl` or +NRL's `3`). Instead ESPN organises cricket as dozens of concurrent, per-tour / +per-season numeric *series* ids. This module therefore: + + 1. Periodically hits the cricket *header* endpoint to list every currently + active series (with its numeric id, name and abbreviation). + 2. Resolves those series against the user's `favorite_competitions` (domestic + league keys from competitions.json, matched by search_terms) and + `favorite_teams` (national teams, matched by the team name appearing in a + series' event names). + 3. Fetches each resolved series' `/scoreboard` and parses matches into a + normalized internal dict. + +Verified live against the real ESPN API (no auth): + header: https://site.web.api.espn.com/apis/v2/scoreboard/header?sport=cricket + series: https://site.api.espn.com/apis/site/v2/sports/cricket/{series_id}/scoreboard + +Field shapes confirmed live: + - competitions[].class.eventType -> "T20" (defensively also handle + "T20I"/"ODI"/"Test"/"Twenty20"/"First-class") + - competitions[].competitors[].score -> e.g. "161/5 (18/20 ov, target 156)" + - competitions[].competitors[].linescores[] -> {period, runs, wickets, overs, + isBatting, description} + - status.type.state -> "pre" | "in" | "post" + - status.summary -> human result string, e.g. "RCB won by 5 wkts (12b rem)" +""" + +import logging +import math +import re +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + +HEADER_URL = "https://site.web.api.espn.com/apis/v2/scoreboard/header?sport=cricket" +SERIES_URL = "https://site.api.espn.com/apis/site/v2/sports/cricket/{series_id}/scoreboard" + +_HEADERS = { + "User-Agent": "LEDMatrix/2.0 (https://github.com/ChuckBuilds/LEDMatrix)", + "Accept": "application/json", +} + +# Default balls-per-innings by format, used only when the score string does not +# already carry a "(x/y ov)" max-overs hint. +FORMAT_MAX_OVERS = { + "T20": 20, + "T20I": 20, + "TWENTY20": 20, + "ODI": 50, + "ODM": 50, + "LIST A": 50, + "THE HUNDRED": 100, # The Hundred counts balls, not overs; handled loosely. +} + +# States that ESPN uses in status.type.state. +STATE_PRE = "pre" +STATE_IN = "in" +STATE_POST = "post" + + +# --------------------------------------------------------------------------- # +# Overs math (base-6 fractional part) -- unit tested in test_cricket_plugin.py +# --------------------------------------------------------------------------- # + +def overs_to_decimal(overs: float) -> float: + """Convert cricket overs notation to a true decimal overs value. + + Cricket overs are base-6 in the fractional part: `18.4` means 18 completed + overs PLUS 4 balls, i.e. 18 + 4/6 = 18.6667 decimal overs -- NOT 18.4. + + Args: + overs: overs in cricket notation (e.g. 18.4, 20.0, 6.2). + + Returns: + Decimal overs suitable for run-rate division (e.g. 18.6667). + """ + if overs is None: + return 0.0 + try: + overs = float(overs) + except (TypeError, ValueError): + return 0.0 + if overs < 0: + return 0.0 + whole = int(math.floor(overs)) + # Recover the balls digit robustly against float noise (e.g. 18.399999). + balls = int(round((overs - whole) * 10)) + if balls >= 6: + # Malformed notation (a legal over only has 0..5 in the fraction); + # roll it forward rather than produce nonsense. + whole += balls // 6 + balls = balls % 6 + return whole + balls / 6.0 + + +def overs_to_balls(overs: float) -> int: + """Return the total number of legal balls represented by an overs value.""" + if overs is None: + return 0 + try: + overs = float(overs) + except (TypeError, ValueError): + return 0 + if overs < 0: + return 0 + whole = int(math.floor(overs)) + balls = int(round((overs - whole) * 10)) + if balls >= 6: + whole += balls // 6 + balls = balls % 6 + return whole * 6 + balls + + +def current_run_rate(runs: int, overs: float) -> Optional[float]: + """Current run rate = runs / decimal_overs. None if no overs bowled yet.""" + dec = overs_to_decimal(overs) + if dec <= 0: + return None + return runs / dec + + +def required_run_rate(runs_needed: int, balls_remaining: int) -> Optional[float]: + """Required run rate for a chase = runs_needed / (balls_remaining / 6). + + Args: + runs_needed: runs still required to win (target - current runs). + balls_remaining: legal balls left in the innings. + + Returns: + Required runs per over, or None when no balls remain. + """ + if balls_remaining <= 0: + return None + if runs_needed < 0: + runs_needed = 0 + return runs_needed / (balls_remaining / 6.0) + + +# --------------------------------------------------------------------------- # +# Fetcher +# --------------------------------------------------------------------------- # + +class CricketDataFetcher: + """Discovers cricket series ids and fetches/parses their scoreboards.""" + + def __init__(self, cache_manager, config: Dict[str, Any], + competitions: List[Dict[str, Any]], request_timeout: int = 30): + self.cache_manager = cache_manager + self.config = config or {} + self.competitions = competitions or [] + self.request_timeout = request_timeout + self.session = requests.Session() + + # competition key -> list of lowercase search terms + self._comp_terms: Dict[str, List[str]] = {} + for comp in self.competitions: + key = comp.get("key") + terms = comp.get("search_terms") or [] + self._comp_terms[key] = [t.lower() for t in terms] + + self._discovery_interval = int(self.config.get("series_discovery_interval", 86400)) + self._last_discovery = 0.0 + self._resolved_series: List[Dict[str, Any]] = [] + + # ---- cache helpers ---------------------------------------------------- # + + def _cache_get(self, key: str, max_age: int) -> Any: + """Best-effort cache read; any cache error is treated as a miss.""" + try: + if self.cache_manager is None: + return None + return self.cache_manager.get(key, max_age=max_age) + except TypeError: + try: + return self.cache_manager.get(key) + except Exception: + return None + except Exception: + return None + + def _cache_set(self, key: str, value: Any, ttl: int) -> None: + try: + if self.cache_manager is None: + return + self.cache_manager.set(key, value, ttl=ttl) + except TypeError: + try: + self.cache_manager.set(key, value) + except Exception: + pass + except Exception: + pass + + # ---- HTTP ------------------------------------------------------------- # + + def _get_json(self, url: str) -> Optional[Dict[str, Any]]: + try: + resp = self.session.get(url, headers=_HEADERS, timeout=self.request_timeout) + if resp.status_code == 200: + payload = resp.json() + if isinstance(payload, dict): + return payload + logger.warning("Cricket API %s returned non-object JSON", url) + return None + logger.warning("Cricket API %s returned HTTP %s", url, resp.status_code) + except Exception as e: # pragma: no cover - network failure path + logger.warning("Cricket API request failed for %s: %s", url, e) + return None + + # ---- series discovery ------------------------------------------------- # + + def _match_competition(self, league: Dict[str, Any], + enabled_keys: List[str]) -> Optional[str]: + """Return the competition key a header league belongs to, or None.""" + haystacks = " ".join( + str(league.get(f, "") or "") + for f in ("name", "shortName", "shortAlternateName", "abbreviation") + ).lower() + for key in enabled_keys: + if key == "international": + continue # international is handled by team matching + for term in self._comp_terms.get(key, []): + if term and term in haystacks: + return key + return None + + @staticmethod + def _league_event_text(league: Dict[str, Any]) -> str: + parts = [str(league.get("name", "") or ""), + str(league.get("shortName", "") or "")] + for ev in league.get("events", []) or []: + parts.append(str(ev.get("name", "") or "")) + parts.append(str(ev.get("shortName", "") or "")) + return " ".join(parts).lower() + + def discover_series(self, force: bool = False) -> List[Dict[str, Any]]: + """Resolve numeric series ids for the configured favorites. + + Returns a list of dicts: {'series_id', 'name', 'competition_key'}. + Cached to `cricket:series_ids` for `series_discovery_interval` seconds. + """ + now = time.time() + if (not force and self._resolved_series + and now - self._last_discovery < self._discovery_interval): + return self._resolved_series + + enabled_keys = list(self.config.get("favorite_competitions", + ["international", "ipl", "bbl"])) + favorite_teams = [t.lower() for t in self.config.get("favorite_teams", [])] + + cache_key = "cricket:series_ids:" + "|".join( + sorted(enabled_keys) + sorted(favorite_teams)) + cached = self._cache_get(cache_key, max_age=self._discovery_interval) + if cached and not force: + self._resolved_series = cached + self._last_discovery = now + return cached + follow_international = "international" in enabled_keys + + data = self._get_json(HEADER_URL) + resolved: List[Dict[str, Any]] = [] + seen_ids = set() + if data: + sports = data.get("sports", []) or [] + leagues = sports[0].get("leagues", []) if sports else [] + for league in leagues: + sid = str(league.get("id", "") or "") + if not sid or sid in seen_ids: + continue + + comp_key = self._match_competition(league, enabled_keys) + + # International: match a favorite national team appearing in the + # series/event names (e.g. "India Women tour of England 2026"). + if comp_key is None and follow_international and favorite_teams: + text = self._league_event_text(league) + if any(team in text for team in favorite_teams): + comp_key = "international" + + if comp_key is None: + continue + + resolved.append({ + "series_id": sid, + "name": league.get("name") or league.get("shortName") or sid, + "competition_key": comp_key, + }) + seen_ids.add(sid) + + if resolved: + self._cache_set(cache_key, resolved, ttl=self._discovery_interval) + # If discovery failed but we have a stale cache, keep using it. + if not resolved and cached: + resolved = cached + + self._resolved_series = resolved + self._last_discovery = now + logger.info("Cricket series discovery resolved %d series: %s", + len(resolved), ", ".join(r["name"] for r in resolved) or "none") + return resolved + + # ---- scoreboard fetch + parse ---------------------------------------- # + + def fetch_matches(self, ttl: int = 300) -> List[Dict[str, Any]]: + """Fetch and parse matches for every resolved series.""" + series = self.discover_series() + matches: List[Dict[str, Any]] = [] + for entry in series: + sid = entry["series_id"] + comp_key = entry["competition_key"] + cache_key = f"cricket:scoreboard:{sid}" + data = self._cache_get(cache_key, max_age=ttl) + if data is None: + data = self._get_json(SERIES_URL.format(series_id=sid)) + if data is not None: + self._cache_set(cache_key, data, ttl=ttl) + if not data: + continue + for ev in data.get("events", []) or []: + parsed = self._parse_event(ev, series_id=sid, + series_name=entry["name"], + competition_key=comp_key) + if parsed: + matches.append(parsed) + return matches + + def _parse_event(self, ev: Dict[str, Any], series_id: str, series_name: str, + competition_key: str) -> Optional[Dict[str, Any]]: + try: + comps = ev.get("competitions", []) or [] + if not comps: + return None + comp = comps[0] + status = ev.get("status") or comp.get("status") or {} + stype = status.get("type", {}) or {} + state = (stype.get("state") or "pre").lower() + + cls = comp.get("class", {}) or {} + event_type = (cls.get("eventType") or cls.get("generalClassCard") or "").strip() + fmt = self._normalize_format(event_type) + is_test = fmt in ("Test", "First-class") + + venue = "" + v = comp.get("venue") or {} + if v: + venue = v.get("fullName") or "" + + teams = [] + for c in comp.get("competitors", []) or []: + teams.append(self._parse_competitor(c)) + # Keep ESPN order (competitors are ordered home/away). + teams.sort(key=lambda t: t.get("order", 0)) + + target = self._parse_target(teams) + + match = { + "id": str(ev.get("id", "")), + "series_id": series_id, + "series_name": series_name, + "competition_key": competition_key, + "name": ev.get("name", ""), + "short_name": ev.get("shortName", ""), + "format": fmt, + "is_test": is_test, + "state": state, + "status_summary": status.get("summary", "") or "", + "status_detail": stype.get("detail", "") or "", + "status_short": stype.get("shortDetail", "") or "", + "period": int(status.get("period", 0) or 0), + "date": comp.get("date") or ev.get("date") or "", + "start_time_utc": self._parse_iso(comp.get("date") or ev.get("date")), + "venue": venue, + "teams": teams, + "target": target, + } + return match + except Exception as e: # pragma: no cover - defensive + logger.warning("Failed to parse cricket event %s: %s", ev.get("id"), e) + return None + + def _parse_competitor(self, c: Dict[str, Any]) -> Dict[str, Any]: + team = c.get("team", {}) or {} + innings = [] + for ls in c.get("linescores", []) or []: + overs_raw = ls.get("overs", 0.0) + innings.append({ + "period": int(ls.get("period", 0) or 0), + "runs": int(ls.get("runs", 0) or 0), + "wickets": int(ls.get("wickets", 0) or 0), + "overs_raw": overs_raw, + "overs_decimal": overs_to_decimal(overs_raw), + "balls": overs_to_balls(overs_raw), + "is_batting": bool(ls.get("isBatting", False)), + "description": ls.get("description", "") or "", + }) + + logo_url = None + logos = team.get("logos") or [] + if logos: + logo_url = logos[0].get("href") + + records = "" + rec = c.get("records") or [] + if rec and isinstance(rec, list): + records = rec[0].get("summary", "") if isinstance(rec[0], dict) else "" + + return { + "order": int(c.get("order", 0) or 0), + "home_away": c.get("homeAway", ""), + "id": str(team.get("id", "") or c.get("id", "")), + "name": team.get("displayName") or team.get("name") or "", + "short_name": team.get("shortDisplayName") or team.get("name") or "", + "abbr": team.get("abbreviation") or "", + "logo_url": logo_url, + "winner": bool(c.get("winner", False)), + "score_str": c.get("score", "") or "", + "innings": innings, + "records": records, + } + + @staticmethod + def _normalize_format(event_type: str) -> str: + et = (event_type or "").strip().upper() + if not et: + return "T20" + if "TEST" in et: + return "Test" + if "FIRST" in et: + return "First-class" + if et in ("T20I", "IT20"): + return "T20I" + if "T20" in et or "TWENTY20" in et or "TWENTY" in et: + return "T20" + if "ODI" in et or "ONE DAY" in et or "ONE-DAY" in et: + return "ODI" + if "HUNDRED" in et: + return "The Hundred" + if "LIST A" in et: + return "ODI" + return event_type.strip() + + @staticmethod + def _parse_target(teams: List[Dict[str, Any]]) -> Optional[int]: + """Extract the chase target from a competitor score string. + + e.g. "161/5 (18/20 ov, target 156)" -> 156 + """ + for t in teams: + m = re.search(r"target\s+(\d+)", t.get("score_str", ""), re.IGNORECASE) + if m: + return int(m.group(1)) + return None + + @staticmethod + def parse_max_overs(score_str: str, fmt: str) -> Optional[int]: + """Extract max overs from a score string like '(18/20 ov...)'. + + Falls back to the format default (T20=20, ODI=50, ...). + """ + m = re.search(r"/\s*(\d+)\s*ov", score_str or "", re.IGNORECASE) + if m: + return int(m.group(1)) + return FORMAT_MAX_OVERS.get((fmt or "").upper()) + + @staticmethod + def _parse_iso(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + v = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(v) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + return None diff --git a/plugins/cricket-scoreboard/cricket_logo_downloader.py b/plugins/cricket-scoreboard/cricket_logo_downloader.py new file mode 100644 index 0000000..822d8b7 --- /dev/null +++ b/plugins/cricket-scoreboard/cricket_logo_downloader.py @@ -0,0 +1,179 @@ +""" +Simplified LogoDownloader for plugin use +""" + +import logging +import requests +from typing import List, Optional +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + +class LogoDownloader: + """Simplified logo downloader for team logos from ESPN API.""" + + def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): + """Initialize the logo downloader with HTTP session and retry logic.""" + self.request_timeout = request_timeout + self.retry_attempts = retry_attempts + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=retry_attempts, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + + # Set up headers + self.headers = { + 'User-Agent': 'LEDMatrix/2.0 (https://github.com/ChuckBuilds/LEDMatrix)', + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + + @staticmethod + def normalize_abbreviation(abbr: str) -> str: + """Normalize team abbreviation for filename.""" + return abbr.upper() + + @staticmethod + def get_logo_filename_variations(abbr: str) -> List[str]: + """Get possible filename variations for a team abbreviation.""" + normalized = LogoDownloader.normalize_abbreviation(abbr) + variations = [f"{normalized}.png"] + + # Add common variations + if normalized == "TA&M": + variations.append("TAANDM.png") + elif normalized == "TAMU": + variations.append("TA&M.png") + + return variations + +_downloader = LogoDownloader() + +def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_path: Path, logo_url: Optional[str] = None) -> bool: + """ + Download missing logo for a team. + + Args: + sport_key: Sport key (e.g., 'nfl', 'ncaa_fb') + team_id: Team ID + team_abbr: Team abbreviation + logo_path: Path where logo should be saved + logo_url: Optional logo URL + + Returns: + True if logo was downloaded successfully, False otherwise + """ + try: + # Ensure directory exists and is writable + logo_dir = logo_path.parent + try: + logo_dir.mkdir(parents=True, exist_ok=True) + + # Check if we can write to the directory + test_file = logo_dir / '.write_test' + try: + test_file.touch() + test_file.unlink() + except PermissionError: + logger.error(f"Permission denied: Cannot write to directory {logo_dir}") + logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except PermissionError as e: + logger.error(f"Permission denied: Cannot create directory {logo_dir}: {e}") + logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to create logo directory {logo_dir}: {e}") + return False + + # If we have a logo URL, try to download it + if logo_url and not logo_url.lower().startswith("https://"): + logger.warning(f"Skipping non-HTTPS logo URL for {team_abbr}: {logo_url}") + logo_url = None + if logo_url: + try: + response = _downloader.session.get(logo_url, headers=_downloader.headers, timeout=_downloader.request_timeout) + if response.status_code == 200: + # Verify it's an image + content_type = response.headers.get('content-type', '').lower() + if any(img_type in content_type for img_type in ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']): + with open(logo_path, 'wb') as f: + f.write(response.content) + logger.info(f"Downloaded logo for {team_abbr} from {logo_url}") + return True + except PermissionError as e: + logger.error(f"Permission denied downloading logo for {team_abbr}: {e}") + logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to download logo for {team_abbr}: {e}") + + # If no URL or download failed, create a placeholder + return create_placeholder_logo(team_abbr, logo_path) + + except PermissionError as e: + logger.error(f"Permission denied for {team_abbr}: {e}") + logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to download logo for {team_abbr}: {e}") + # Try to create placeholder as fallback + try: + return create_placeholder_logo(team_abbr, logo_path) + except Exception: + return False + +def create_placeholder_logo(team_abbr: str, logo_path: Path) -> bool: + """Create a simple placeholder logo.""" + try: + # Ensure directory exists + logo_path.parent.mkdir(parents=True, exist_ok=True) + + # Create a simple text-based logo + img = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Try to load a font + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + except (IOError, OSError): + font = ImageFont.load_default() + + # Draw team abbreviation + text = team_abbr[:3] # Limit to 3 characters + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (64 - text_width) // 2 + y = (64 - text_height) // 2 + + # Draw white text with black outline + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=(0, 0, 0)) + draw.text((x, y), text, font=font, fill=(255, 255, 255)) + + # Save the placeholder + img.save(logo_path) + logger.info(f"Created placeholder logo for {team_abbr}") + return True + + except PermissionError as e: + logger.error(f"Permission denied creating placeholder logo for {team_abbr}: {e}") + logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to create placeholder logo for {team_abbr}: {e}") + return False diff --git a/plugins/cricket-scoreboard/cricket_renderer.py b/plugins/cricket-scoreboard/cricket_renderer.py new file mode 100644 index 0000000..36562e8 --- /dev/null +++ b/plugins/cricket-scoreboard/cricket_renderer.py @@ -0,0 +1,438 @@ +""" +Cricket scoreboard renderer. + +One render method per match state, mirroring the separate-renderer-per-view +pattern used by the olympics plugin's renderers: + + render_live_limited_overs -> live T20 / ODI (overs clock, run rate, target/RRR) + render_live_test -> live Test / first-class (day + session, no clock) + render_recent -> completed match (final scores + result summary) + render_upcoming -> scheduled match (date/time, teams, venue, format) + +Each method returns a PIL Image sized (width, height) that the manager pastes +onto the display_manager and pushes with update_display(). +""" + +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageFont + +from cricket_data_fetcher import ( + CricketDataFetcher, + current_run_rate, + required_run_rate, +) + +logger = logging.getLogger(__name__) + +_PLUGIN_DIR = Path(__file__).resolve().parent + +# Font search paths: bundled first, then the core LEDMatrix asset dirs. +_FONT_DIRS = [ + _PLUGIN_DIR / "assets" / "fonts", + Path("assets") / "fonts", + _PLUGIN_DIR.parent.parent / "assets" / "fonts", + Path.home() / "Github" / "LEDMatrix" / "assets" / "fonts", +] + +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +GREY = (170, 170, 170) +YELLOW = (255, 210, 0) +GREEN = (0, 255, 102) + + +def _hex_to_rgb(value: str, default: Tuple[int, int, int]) -> Tuple[int, int, int]: + if not value: + return default + v = value.lstrip("#") + if len(v) != 6: + return default + try: + return (int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)) + except ValueError: + return default + + +class CricketRenderer: + """Renders cricket match cards for the LED matrix.""" + + def __init__(self, display_width: int, display_height: int, + config: Dict[str, Any]): + self.width = display_width + self.height = display_height + self.config = config or {} + + custom = self.config.get("customization", {}) or {} + colors = custom.get("colors", {}) or {} + self.score_color = _hex_to_rgb(colors.get("score_color"), WHITE) + self.batting_color = _hex_to_rgb(colors.get("batting_color"), GREEN) + self.detail_color = _hex_to_rgb(colors.get("detail_color"), YELLOW) + self.status_color = _hex_to_rgb(colors.get("status_color"), GREY) + + self._fonts: Dict[str, ImageFont.ImageFont] = {} + self._load_fonts(custom) + + self._img_cache: Dict[str, Optional[Image.Image]] = {} + + # ---- fonts ------------------------------------------------------------ # + + @staticmethod + def _find_font(filename: str) -> Optional[str]: + for d in _FONT_DIRS: + p = Path(d) / filename + if p.exists(): + return str(p) + return None + + def _load_one(self, element_cfg: Dict[str, Any], default_font: str, + default_size: int) -> ImageFont.ImageFont: + name = (element_cfg or {}).get("font", default_font) + size = int((element_cfg or {}).get("font_size", default_size)) + path = self._find_font(name) or self._find_font(default_font) + if path: + try: + return ImageFont.truetype(path, size) + except Exception as e: + logger.warning("Font load failed %s@%s: %s", path, size, e) + return ImageFont.load_default() + + def _load_fonts(self, custom: Dict[str, Any]) -> None: + self._fonts["score"] = self._load_one(custom.get("score_text"), + "PressStart2P-Regular.ttf", 10) + self._fonts["period"] = self._load_one(custom.get("period_text"), + "PressStart2P-Regular.ttf", 8) + self._fonts["team"] = self._load_one(custom.get("team_name"), + "PressStart2P-Regular.ttf", 8) + self._fonts["status"] = self._load_one(custom.get("status_text"), + "4x6-font.ttf", 6) + self._fonts["detail"] = self._load_one(custom.get("detail_text"), + "4x6-font.ttf", 6) + + # ---- draw helpers ----------------------------------------------------- # + + def _text_size(self, draw: ImageDraw.ImageDraw, text: str, + font: ImageFont.ImageFont) -> Tuple[int, int]: + try: + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0], bbox[3] - bbox[1] + except AttributeError: + return len(text) * 6, 8 + + def _draw_centered(self, draw: ImageDraw.ImageDraw, text: str, + cx: int, y: int, font: ImageFont.ImageFont, + fill: Tuple[int, int, int]) -> None: + w, _ = self._text_size(draw, text, font) + draw.text((cx - w // 2, y), text, font=font, fill=fill) + + def _blank(self) -> Tuple[Image.Image, ImageDraw.ImageDraw]: + img = Image.new("RGB", (self.width, self.height), BLACK) + return img, ImageDraw.Draw(img) + + # ---- team crest (flag / logo / placeholder) --------------------------- # + + def _load_crest(self, team: Dict[str, Any], size: int) -> Optional[Image.Image]: + """Load a national-team flag or franchise logo, cached by key+size.""" + abbr = (team.get("abbr") or "").upper() + name = (team.get("name") or "").lower().replace(" ", "_") + cache_key = f"{abbr}:{name}:{size}" + if cache_key in self._img_cache: + return self._img_cache[cache_key] + + candidates: List[Path] = [] + flags_dir = _PLUGIN_DIR / "assets" / "flags" + logos_dir = _PLUGIN_DIR / "assets" / "logos" + if abbr: + candidates += [flags_dir / f"{abbr}.png", logos_dir / f"{abbr}.png"] + if name: + candidates += [flags_dir / f"{name}.png", logos_dir / f"{name}.png"] + + img = None + for path in candidates: + if path.exists(): + try: + with Image.open(path) as raw: + crest = raw.convert("RGBA").resize((size, size), Image.NEAREST) + crest.load() + img = crest + break + except Exception as e: + logger.debug("Crest load failed %s: %s", path, e) + + self._img_cache[cache_key] = img + return img + + def _draw_team_crest(self, img: Image.Image, draw: ImageDraw.ImageDraw, + team: Dict[str, Any], x: int, y: int, size: int) -> None: + crest = self._load_crest(team, size) + if crest is not None: + img.paste(crest, (x, y), crest) + return + # Text placeholder box. + draw.rectangle([x, y, x + size - 1, y + size - 1], outline=GREY) + abbr = (team.get("abbr") or team.get("short_name") or "?")[:3] + w, h = self._text_size(draw, abbr, self._fonts["team"]) + draw.text((x + (size - w) // 2, y + (size - h) // 2), abbr, + font=self._fonts["team"], fill=WHITE) + + # ---- innings helpers -------------------------------------------------- # + + @staticmethod + def _batting_innings(team: Dict[str, Any]) -> Optional[Dict[str, Any]]: + innings = team.get("innings") or [] + if not innings: + return None + for inn in innings: + if inn.get("is_batting"): + return inn + return innings[-1] + + @staticmethod + def _score_text(team: Dict[str, Any]) -> str: + inn = CricketRenderer._batting_innings(team) + if not inn: + return "-" + return f"{inn['runs']}/{inn['wickets']}" + + # ---- public render methods ------------------------------------------- # + + def render_live_limited_overs(self, match: Dict[str, Any]) -> Image.Image: + """Live T20/ODI card: crests, runs/wickets (overs/max ov), run rates.""" + img, draw = self._blank() + teams = match.get("teams", []) + if len(teams) < 2: + return self._render_message(match.get("name", "Cricket"), "No data") + + home, away = teams[0], teams[1] + crest = min(self.height - 12, 16) + self._draw_team_crest(img, draw, away, 1, 1, crest) + self._draw_team_crest(img, draw, home, self.width - crest - 1, 1, crest) + + # Format badge / series top-center. + self._draw_centered(draw, match.get("format", "T20"), + self.width // 2, 0, self._fonts["status"], + self.status_color) + + # Abbreviations under crests. + away_abbr = (away.get("abbr") or away.get("short_name") or "")[:4] + home_abbr = (home.get("abbr") or home.get("short_name") or "")[:4] + self._draw_centered(draw, away_abbr, 1 + crest // 2, crest + 1, + self._fonts["status"], WHITE) + self._draw_centered(draw, home_abbr, self.width - crest // 2 - 1, crest + 1, + self._fonts["status"], WHITE) + + # Scores center. + away_inn = self._batting_innings(away) + home_inn = self._batting_innings(home) + away_batting = bool(away_inn and away_inn.get("is_batting")) + home_batting = bool(home_inn and home_inn.get("is_batting")) + + cx = self.width // 2 + away_score = self._score_text(away) + home_score = self._score_text(home) + self._draw_centered(draw, away_score, cx, 6, self._fonts["score"], + self.batting_color if away_batting else self.score_color) + self._draw_centered(draw, home_score, cx, 6 + 11, self._fonts["score"], + self.batting_color if home_batting else self.score_color) + + # Detail line: overs + run rate for the batting side; RRR/target on chase. + detail = self._live_detail(match, away, home, away_inn, home_inn, + away_batting, home_batting) + if detail: + self._draw_centered(draw, detail, cx, self.height - 7, + self._fonts["detail"], self.detail_color) + return img + + def _live_detail(self, match, away, home, away_inn, home_inn, + away_batting, home_batting) -> str: + bat_team = away if away_batting else (home if home_batting else None) + bat_inn = away_inn if away_batting else (home_inn if home_batting else None) + if not bat_inn: + return match.get("status_short", "") or "" + + max_overs = CricketDataFetcher.parse_max_overs( + bat_team.get("score_str", ""), match.get("format", "")) + overs_disp = self._format_overs(bat_inn.get("overs_raw", 0.0)) + if max_overs: + overs_txt = f"{overs_disp}/{max_overs} ov" + else: + overs_txt = f"{overs_disp} ov" + + crr = current_run_rate(bat_inn.get("runs", 0), bat_inn.get("overs_raw", 0.0)) + parts = [overs_txt] + if crr is not None: + parts.append(f"RR {crr:.1f}") + + target = match.get("target") + if target and max_overs: + runs_needed = target - bat_inn.get("runs", 0) + balls_bowled = bat_inn.get("balls", 0) + balls_remaining = max_overs * 6 - balls_bowled + rrr = required_run_rate(runs_needed, balls_remaining) + if runs_needed > 0 and rrr is not None: + parts.append(f"need {runs_needed} RRR {rrr:.1f}") + return " ".join(parts) + + def render_live_test(self, match: Dict[str, Any]) -> Image.Image: + """Live Test card: no clock -- day + session, both innings scores.""" + img, draw = self._blank() + teams = match.get("teams", []) + if len(teams) < 2: + return self._render_message(match.get("name", "Cricket"), "No data") + home, away = teams[0], teams[1] + + crest = min(self.height - 12, 16) + self._draw_team_crest(img, draw, away, 1, 1, crest) + self._draw_team_crest(img, draw, home, self.width - crest - 1, 1, crest) + + # Session/day from status text (e.g. "Stumps", "Lunch", "Day 3"). + session = self._test_session(match) + self._draw_centered(draw, session or "Test", self.width // 2, 0, + self._fonts["status"], self.status_color) + + cx = self.width // 2 + away_score = self._test_score_text(away) + home_score = self._test_score_text(home) + self._draw_centered(draw, (away.get("abbr") or "")[:4] + " " + away_score, + cx, 8, self._fonts["detail"], self.score_color) + self._draw_centered(draw, (home.get("abbr") or "")[:4] + " " + home_score, + cx, 8 + 8, self._fonts["detail"], self.score_color) + + lead = match.get("status_short") or match.get("status_summary") or "" + if lead: + self._draw_centered(draw, lead[:26], cx, self.height - 7, + self._fonts["detail"], self.detail_color) + return img + + @staticmethod + def _test_score_text(team: Dict[str, Any]) -> str: + innings = team.get("innings") or [] + if not innings: + return "-" + pieces = [] + for inn in innings: + wk = inn.get("wickets", 0) + runs = inn.get("runs", 0) + if wk >= 10 or inn.get("description", "").lower() in ("declared", "all out"): + pieces.append(f"{runs}") + else: + pieces.append(f"{runs}/{wk}") + return " & ".join(pieces) + + def _test_session(self, match: Dict[str, Any]) -> str: + for key in ("status_short", "status_detail", "status_summary"): + txt = match.get(key) or "" + for token in ("Stumps", "Lunch", "Tea", "Close", "Drinks", + "Innings Break", "Day"): + if token.lower() in txt.lower(): + return txt[:22] + period = match.get("period", 0) + if period: + return f"Day {period}" + return "Test" + + def render_recent(self, match: Dict[str, Any]) -> Image.Image: + """Completed match: final scores + result summary.""" + img, draw = self._blank() + teams = match.get("teams", []) + if len(teams) < 2: + return self._render_message(match.get("name", "Cricket"), "Final") + home, away = teams[0], teams[1] + + crest = min(self.height - 14, 14) + self._draw_team_crest(img, draw, away, 1, 1, crest) + self._draw_team_crest(img, draw, home, self.width - crest - 1, 1, crest) + + self._draw_centered(draw, "FINAL", self.width // 2, 0, + self._fonts["status"], self.status_color) + + cx = self.width // 2 + away_line = f"{(away.get('abbr') or '')[:4]} {self._final_score(away)}" + home_line = f"{(home.get('abbr') or '')[:4]} {self._final_score(home)}" + away_col = GREEN if away.get("winner") else self.score_color + home_col = GREEN if home.get("winner") else self.score_color + self._draw_centered(draw, away_line, cx, 7, self._fonts["detail"], away_col) + self._draw_centered(draw, home_line, cx, 7 + 8, self._fonts["detail"], home_col) + + summary = match.get("status_summary", "") + if summary: + self._draw_centered(draw, summary[:30], cx, self.height - 7, + self._fonts["status"], self.detail_color) + return img + + @staticmethod + def _final_score(team: Dict[str, Any]) -> str: + innings = team.get("innings") or [] + if not innings: + return team.get("score_str", "").split(" ")[0] or "-" + # Prefer the innings that actually has runs (its last batted innings). + inn = innings[-1] + for i in reversed(innings): + if i.get("runs", 0) or i.get("wickets", 0): + inn = i + break + return f"{inn['runs']}/{inn['wickets']}" + + def render_upcoming(self, match: Dict[str, Any]) -> Image.Image: + """Scheduled match: date/time, teams, venue, format badge.""" + img, draw = self._blank() + teams = match.get("teams", []) + cx = self.width // 2 + + self._draw_centered(draw, match.get("format", "Cricket"), cx, 0, + self._fonts["status"], self.detail_color) + + if len(teams) >= 2: + home, away = teams[0], teams[1] + crest = min(self.height - 14, 14) + self._draw_team_crest(img, draw, away, 1, 8, crest) + self._draw_team_crest(img, draw, home, self.width - crest - 1, 8, crest) + matchup = f"{(away.get('abbr') or away.get('short_name') or '')[:4]} v {(home.get('abbr') or home.get('short_name') or '')[:4]}" + self._draw_centered(draw, matchup, cx, 8, self._fonts["team"], WHITE) + + dt = match.get("start_time_utc") + when = self._format_datetime(dt) if dt else "" + if when: + self._draw_centered(draw, when, cx, self.height - 14, + self._fonts["status"], self.score_color) + + if self.config.get("show_venue", True): + venue = match.get("venue", "") + if venue: + self._draw_centered(draw, venue[:30], cx, self.height - 7, + self._fonts["status"], self.status_color) + return img + + # ---- misc ------------------------------------------------------------- # + + def _render_message(self, title: str, msg: str) -> Image.Image: + img, draw = self._blank() + cx = self.width // 2 + self._draw_centered(draw, title[:20], cx, self.height // 2 - 8, + self._fonts["team"], WHITE) + self._draw_centered(draw, msg[:24], cx, self.height // 2 + 2, + self._fonts["status"], GREY) + return img + + @staticmethod + def _format_overs(overs_raw: float) -> str: + try: + overs_raw = float(overs_raw) + except (TypeError, ValueError): + return "0" + whole = int(overs_raw) + balls = int(round((overs_raw - whole) * 10)) + if balls <= 0: + return f"{whole}" + return f"{whole}.{balls}" + + @staticmethod + def _format_datetime(dt: datetime) -> str: + try: + return dt.strftime("%b %d %H:%M") + except Exception: + return "" diff --git a/plugins/cricket-scoreboard/manager.py b/plugins/cricket-scoreboard/manager.py new file mode 100644 index 0000000..5ceee12 --- /dev/null +++ b/plugins/cricket-scoreboard/manager.py @@ -0,0 +1,478 @@ +""" +Cricket Scoreboard plugin. + +CricketScoreboardPlugin orchestrates series discovery, data fetching, match +categorization (live / recent / upcoming), logo/flag resolution and per-mode +rotation. It mirrors the lifecycle and config surface of the other sports +scoreboard plugins (see soccer-scoreboard/manager.py) while using a cricket- +specific data model and renderer. + +Display modes: cricket_live, cricket_recent, cricket_upcoming. +""" + +import json +import logging +import os +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from PIL import Image + +# Guarded BasePlugin import so this module also compiles / runs standalone +# (e.g. under the test harness) without the core LEDMatrix package present. +try: + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode +except ImportError: + BasePlugin = None + VegasDisplayMode = None + +try: + from src.background_data_service import get_background_service +except ImportError: + get_background_service = None + +from cricket_data_fetcher import CricketDataFetcher +from cricket_renderer import CricketRenderer + +try: + from cricket_logo_downloader import download_missing_logo +except ImportError: # pragma: no cover + download_missing_logo = None + +logger = logging.getLogger(__name__) + +_PLUGIN_DIR = Path(__file__).resolve().parent + +MODE_LIVE = "cricket_live" +MODE_RECENT = "cricket_recent" +MODE_UPCOMING = "cricket_upcoming" + +# ESPN competition keys that represent bundled national-team flags rather than +# auto-downloaded franchise logos. +_INTERNATIONAL_KEY = "international" + + +class CricketScoreboardPlugin(BasePlugin if BasePlugin else object): + """Cricket scoreboard plugin (international + major domestic T20 leagues).""" + + def __init__(self, plugin_id, config, display_manager, cache_manager, + plugin_manager): + if BasePlugin: + super().__init__(plugin_id, config, display_manager, cache_manager, + plugin_manager) + + self.plugin_id = plugin_id + self.config = config or {} + self.display_manager = display_manager + self.cache_manager = cache_manager + self.plugin_manager = plugin_manager + self.logger = logger + + self.is_enabled = self.config.get("enabled", False) + + if hasattr(display_manager, "matrix") and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Load the curated competition seed list. + self.competitions = self._load_competitions() + + # Durations / intervals. + self.display_duration = float(self.config.get("display_duration", 15)) + self.live_game_duration = float(self.config.get("live_game_duration", 20)) + self.non_favorite_live_game_duration = float( + self.config.get("non_favorite_live_game_duration", 0)) + self.recent_game_duration = float(self.config.get("recent_game_duration", 15)) + self.upcoming_game_duration = float(self.config.get("upcoming_game_duration", 15)) + self.update_interval = int(self.config.get("update_interval_seconds", 3600)) + self.live_update_interval = int(self.config.get("live_update_interval", 30)) + self.recent_games_to_show = int(self.config.get("recent_games_to_show", 5)) + self.upcoming_games_to_show = int(self.config.get("upcoming_games_to_show", 5)) + self.live_priority = bool(self.config.get("live_priority", True)) + + self.favorite_teams = [t.lower() for t in self.config.get("favorite_teams", [])] + self.exclude_teams = [t.lower() for t in self.config.get("exclude_teams", [])] + self.show_favorite_teams_only = bool( + self.config.get("show_favorite_teams_only", False)) + + display_modes = self.config.get("display_modes", {}) or {} + self.mode_enabled = { + MODE_LIVE: display_modes.get("live", True), + MODE_RECENT: display_modes.get("recent", True), + MODE_UPCOMING: display_modes.get("upcoming", True), + } + + bg_cfg = self.config.get("background_service", {}) or {} + req_timeout = int(bg_cfg.get("request_timeout", 30)) + + self.fetcher = CricketDataFetcher(cache_manager, self.config, + self.competitions, request_timeout=req_timeout) + self.renderer = CricketRenderer(self.display_width, self.display_height, + self.config) + + # Optional background service (best-effort, same pattern as soccer). + self.background_service = None + if get_background_service: + try: + self.background_service = get_background_service(cache_manager, max_workers=1) + except Exception as e: + self.logger.warning("Cricket background service init failed: %s", e) + + # Categorized match state. + self._lock = threading.Lock() + self.live_matches: List[Dict[str, Any]] = [] + self.recent_matches: List[Dict[str, Any]] = [] + self.upcoming_matches: List[Dict[str, Any]] = [] + + self._last_update = 0.0 + + # Per-mode rotation state. + self._mode_index: Dict[str, int] = {MODE_LIVE: 0, MODE_RECENT: 0, MODE_UPCOMING: 0} + self._mode_item_start: Dict[str, float] = {} + + self.logos_dir = _PLUGIN_DIR / "assets" / "logos" + self.logos_dir.mkdir(parents=True, exist_ok=True) + + self.logger.info("Cricket scoreboard initialized (%dx%d), %d competitions seeded", + self.display_width, self.display_height, len(self.competitions)) + + # ------------------------------------------------------------------ # + # Setup helpers + # ------------------------------------------------------------------ # + + def _load_competitions(self) -> List[Dict[str, Any]]: + path = _PLUGIN_DIR / "competitions.json" + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + self.logger.error("Failed to load competitions.json: %s", e) + return [{"key": "international", "name": "International", "search_terms": None}] + + # ------------------------------------------------------------------ # + # Filtering + # ------------------------------------------------------------------ # + + def _match_has_team(self, match: Dict[str, Any], names: List[str]) -> bool: + for team in match.get("teams", []): + hay = f"{team.get('name', '')} {team.get('abbr', '')} {team.get('short_name', '')}".lower() + if any(n and n in hay for n in names): + return True + return False + + def _keep_match(self, match: Dict[str, Any]) -> bool: + if self.exclude_teams and self._match_has_team(match, self.exclude_teams): + return False + # show_favorite_teams_only only constrains international matches; domestic + # competitions are already the user's chosen favorites. + if (self.show_favorite_teams_only and self.favorite_teams + and match.get("competition_key") == _INTERNATIONAL_KEY): + if not self._match_has_team(match, self.favorite_teams): + return False + return True + + def _match_is_favorite(self, match: Dict[str, Any]) -> bool: + return bool(self.favorite_teams) and self._match_has_team(match, self.favorite_teams) + + # ------------------------------------------------------------------ # + # Update + # ------------------------------------------------------------------ # + + def update(self) -> None: + if not self.is_enabled: + return + now = time.time() + # Faster refresh when live content is present. + has_live = bool(self.live_matches) + interval = self.live_update_interval if has_live else self.update_interval + if now - self._last_update < interval: + return + self._last_update = now + + try: + ttl = self.live_update_interval if has_live else self.update_interval + matches = self.fetcher.fetch_matches(ttl=ttl) + except Exception as e: + self.logger.warning("Cricket fetch_matches failed: %s", e) + return + + live, recent, upcoming = [], [], [] + for m in matches: + if not self._keep_match(m): + continue + state = m.get("state") + if state == "in": + live.append(m) + elif state == "post": + recent.append(m) + else: + upcoming.append(m) + + # Sort: favorites first for live; most-recent-first for recent; soonest + # first for upcoming. + live.sort(key=lambda m: (not self._match_is_favorite(m), + m.get("start_time_utc") or _min_dt())) + recent.sort(key=lambda m: m.get("start_time_utc") or _min_dt(), reverse=True) + upcoming.sort(key=lambda m: m.get("start_time_utc") or _max_dt()) + + recent = recent[: self.recent_games_to_show] + upcoming = upcoming[: self.upcoming_games_to_show] + + # Auto-download franchise logos (national teams use bundled flags). + for m in live + recent + upcoming: + if m.get("competition_key") != _INTERNATIONAL_KEY: + self._ensure_logos(m) + + with self._lock: + self.live_matches = live + self.recent_matches = recent + self.upcoming_matches = upcoming + + self.logger.info("Cricket update: %d live, %d recent, %d upcoming", + len(live), len(recent), len(upcoming)) + + def _ensure_logos(self, match: Dict[str, Any]) -> None: + if download_missing_logo is None: + return + for team in match.get("teams", []): + abbr = (team.get("abbr") or "").upper() + abbr = "".join(c for c in abbr if c.isalnum() or c in ("&", "_")) + url = team.get("logo_url") + if not abbr or not url: + continue + path = self.logos_dir / f"{abbr}.png" + if path.parent != self.logos_dir: + continue + if path.exists(): + continue + try: + download_missing_logo("cricket", team.get("id", ""), abbr, path, url) + except Exception as e: + self.logger.debug("Logo download failed for %s: %s", abbr, e) + + # ------------------------------------------------------------------ # + # Display + # ------------------------------------------------------------------ # + + def _matches_for_mode(self, mode: str) -> List[Dict[str, Any]]: + with self._lock: + if mode == MODE_LIVE: + return list(self.live_matches) + if mode == MODE_RECENT: + return list(self.recent_matches) + if mode == MODE_UPCOMING: + return list(self.upcoming_matches) + return [] + + def _per_item_duration(self, mode: str, match: Optional[Dict[str, Any]]) -> float: + if mode == MODE_LIVE: + if (self.non_favorite_live_game_duration > 0 and match is not None + and not self._match_is_favorite(match)): + return self.non_favorite_live_game_duration + return self.live_game_duration + if mode == MODE_RECENT: + return self.recent_game_duration + if mode == MODE_UPCOMING: + return self.upcoming_game_duration + return self.display_duration + + def _current_match(self, mode: str) -> Optional[Dict[str, Any]]: + matches = self._matches_for_mode(mode) + if not matches: + return None + idx = self._mode_index.get(mode, 0) % len(matches) + match = matches[idx] + + now = time.time() + start = self._mode_item_start.get(mode) + if start is None: + self._mode_item_start[mode] = now + elif now - start >= self._per_item_duration(mode, match): + idx = (idx + 1) % len(matches) + self._mode_index[mode] = idx + self._mode_item_start[mode] = now + match = matches[idx] + return match + + def _render_match(self, mode: str, match: Dict[str, Any]) -> Image.Image: + if mode == MODE_LIVE: + if match.get("is_test"): + return self.renderer.render_live_test(match) + return self.renderer.render_live_limited_overs(match) + if mode == MODE_RECENT: + return self.renderer.render_recent(match) + return self.renderer.render_upcoming(match) + + def display(self, display_mode: str = None, force_clear: bool = False) -> bool: + if not self.is_enabled: + return False + + mode = display_mode or self._pick_default_mode() + if mode not in (MODE_LIVE, MODE_RECENT, MODE_UPCOMING): + return False + if not self.mode_enabled.get(mode, True): + return False + + match = self._current_match(mode) + if match is None: + return False + + try: + image = self._render_match(mode, match) + except Exception as e: + self.logger.warning("Cricket render failed (%s): %s", mode, e) + return False + + try: + if force_clear and hasattr(self.display_manager, "clear"): + self.display_manager.clear() + self.display_manager.image.paste(image, (0, 0)) + self.display_manager.update_display() + except Exception as e: + self.logger.warning("Cricket display push failed: %s", e) + return False + return True + + def _pick_default_mode(self) -> str: + if self.mode_enabled.get(MODE_LIVE, True) and self.live_matches: + return MODE_LIVE + if self.mode_enabled.get(MODE_RECENT, True) and self.recent_matches: + return MODE_RECENT + if self.mode_enabled.get(MODE_UPCOMING, True) and self.upcoming_matches: + return MODE_UPCOMING + return MODE_LIVE + + # ------------------------------------------------------------------ # + # Duration / cycling contract + # ------------------------------------------------------------------ # + + def get_display_duration(self, display_mode: str = None) -> float: + return self.get_cycle_duration(display_mode) or self.display_duration + + def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: + mode = display_mode or self._pick_default_mode() + matches = self._matches_for_mode(mode) + if not matches: + return self.display_duration + + # Explicit mode-level override wins. + mode_durations = self.config.get("mode_durations", {}) or {} + override = mode_durations.get(f"{mode.replace('cricket_', '')}_mode_duration") + if override: + return float(override) + + per = sum(self._per_item_duration(mode, m) for m in matches) + dyn = self.config.get("dynamic_duration", {}) or {} + if dyn.get("enabled", False): + lo = float(dyn.get("min_duration_seconds", 30)) + hi = float(dyn.get("max_duration_seconds", 300)) + return max(lo, min(per, hi)) + return per + + def supports_dynamic_duration(self) -> bool: + return bool((self.config.get("dynamic_duration", {}) or {}).get("enabled", False)) + + # ------------------------------------------------------------------ # + # Live priority contract + # ------------------------------------------------------------------ # + + def has_live_priority(self) -> bool: + return self.is_enabled and self.live_priority + + def has_live_content(self) -> bool: + if not self.is_enabled or not self.mode_enabled.get(MODE_LIVE, True): + return False + with self._lock: + return len(self.live_matches) > 0 + + def get_live_modes(self) -> List[str]: + return [MODE_LIVE] if self.has_live_content() else [] + + # ------------------------------------------------------------------ # + # Vegas hooks + # ------------------------------------------------------------------ # + + def get_vegas_content_type(self) -> str: + return "multi" + + def get_vegas_content(self) -> Optional[List[Image.Image]]: + cards: List[Image.Image] = [] + for mode in (MODE_LIVE, MODE_RECENT, MODE_UPCOMING): + if not self.mode_enabled.get(mode, True): + continue + for match in self._matches_for_mode(mode): + try: + cards.append(self._render_match(mode, match)) + except Exception as e: + self.logger.debug("Vegas card render failed: %s", e) + return cards or None + + def get_vegas_display_mode(self): + if VegasDisplayMode is not None: + return VegasDisplayMode.SCROLL + return None + + # ------------------------------------------------------------------ # + # Config lifecycle + # ------------------------------------------------------------------ # + + def validate_config(self, config: Dict[str, Any] = None) -> bool: + cfg = config if config is not None else self.config + if not isinstance(cfg, dict): + return False + comps = cfg.get("favorite_competitions", []) + if comps and not isinstance(comps, list): + return False + teams = cfg.get("favorite_teams", []) + if teams and not isinstance(teams, list): + return False + return True + + def on_config_change(self, new_config: Dict[str, Any]) -> None: + if BasePlugin: + try: + super().on_config_change(new_config) + except Exception: + self.logger.debug("BasePlugin on_config_change failed", exc_info=True) + # Re-run __init__ derived state without recreating the object, but + # preserve the lock in place -- another thread may be holding it + # inside update()/display(), and replacing it would break mutual + # exclusion. + lock = self._lock + self.__init__(self.plugin_id, new_config, self.display_manager, + self.cache_manager, self.plugin_manager) + self._lock = lock + # Force a re-discovery + refetch on next update. + self._last_update = 0.0 + + def get_info(self) -> Dict[str, Any]: + info = {} + if BasePlugin: + try: + info = super().get_info() + except Exception: + info = {} + info.update({ + "name": "Cricket Scoreboard", + "enabled": self.is_enabled, + "live": len(self.live_matches), + "recent": len(self.recent_matches), + "upcoming": len(self.upcoming_matches), + "competitions": [c.get("key") for c in self.competitions], + }) + return info + + +def _min_dt(): + from datetime import datetime, timezone + return datetime.min.replace(tzinfo=timezone.utc) + + +def _max_dt(): + from datetime import datetime, timezone + return datetime.max.replace(tzinfo=timezone.utc) diff --git a/plugins/cricket-scoreboard/manifest.json b/plugins/cricket-scoreboard/manifest.json new file mode 100644 index 0000000..e9ac40d --- /dev/null +++ b/plugins/cricket-scoreboard/manifest.json @@ -0,0 +1,55 @@ +{ + "id": "cricket-scoreboard", + "name": "Cricket Scoreboard", + "version": "1.0.2", + "author": "ChuckBuilds", + "description": "Live, recent, and upcoming cricket matches across international tours (Test/ODI/T20I) and major domestic T20 leagues including the IPL, Big Bash League, The Hundred, PSL, CPL, SA20 and more. Shows runs/wickets/overs, run rates, targets, and match results.", + "category": "sports", + "tags": [ + "cricket", + "ipl", + "big-bash", + "t20", + "test-cricket", + "odi", + "sports", + "scoreboard", + "live-scores" + ], + "display_modes": [ + "cricket_live", + "cricket_recent", + "cricket_upcoming" + ], + "versions": [ + { + "released": "2026-07-10", + "version": "1.0.2", + "notes": "Address Codacy security/lint findings: drop the unused http:// adapter mount in the logo downloader now that only HTTPS logo URLs are ever requested, and fix an implicit-Optional type annotation on download_missing_logo's logo_url parameter.", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-07-10", + "version": "1.0.1", + "notes": "Fix CodeRabbit findings: reject empty favorite_teams entries, reject negative overs in overs_to_balls, validate ESPN JSON response shape, scope series discovery cache by configured filters, rename logo_downloader.py to cricket_logo_downloader.py to avoid cross-plugin module collisions, require HTTPS logo URLs and sanitize team abbreviations used in logo filenames, and preserve the update lock across on_config_change.", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-07-10", + "version": "1.0.0", + "notes": "Initial release. Series-ID discovery via the ESPN cricket header endpoint, favorite national teams + favorite domestic competitions dual filter, live limited-overs / live Test / recent / upcoming render views, base-6 overs-to-decimal run-rate math, franchise logo auto-download and bundled national-team flags.", + "ledmatrix_min": "2.0.0" + } + ], + "last_updated": "2026-07-10", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "", + "entry_point": "manager.py", + "class_name": "CricketScoreboardPlugin", + "config_schema": "config_schema.json", + "compatible_versions": [ + ">=2.0.0" + ] +} diff --git a/plugins/cricket-scoreboard/requirements.txt b/plugins/cricket-scoreboard/requirements.txt new file mode 100644 index 0000000..084d337 --- /dev/null +++ b/plugins/cricket-scoreboard/requirements.txt @@ -0,0 +1,12 @@ +# Cricket Scoreboard Plugin Requirements + +# Core dependencies (handled by main LEDMatrix) +# requests - for ESPN cricket API calls +# pytz - for timezone handling +# Pillow - for image manipulation + +# These are already included in the main LEDMatrix requirements +# but listed here for reference: +# requests>=2.25.0 +# pytz>=2021.1 +# Pillow>=8.0.0 diff --git a/plugins/cricket-scoreboard/test/harness.json b/plugins/cricket-scoreboard/test/harness.json new file mode 100644 index 0000000..b6f44f6 --- /dev/null +++ b/plugins/cricket-scoreboard/test/harness.json @@ -0,0 +1,108 @@ +{ + "_comment": "Deterministic harness config + mocked ESPN cricket responses for golden-image rendering. mock_matches is consumed directly by test_cricket_plugin.py (the plugin's live fetcher is bypassed) and covers every format branch: a live T20 chase, a live Test with a session, a completed match, and an upcoming fixture.", + "config": { + "enabled": true, + "favorite_teams": ["India", "Australia", "England"], + "favorite_competitions": ["international", "ipl", "bbl"], + "display_duration": 15, + "live_game_duration": 20, + "recent_game_duration": 15, + "upcoming_game_duration": 15, + "recent_games_to_show": 5, + "upcoming_games_to_show": 5, + "live_priority": true, + "show_venue": true, + "display_modes": {"live": true, "recent": true, "upcoming": true} + }, + "freeze_time": "2026-05-31 15:30:00", + "mock_matches": [ + { + "id": "live-t20", + "series_id": "8048", + "series_name": "Indian Premier League", + "competition_key": "ipl", + "name": "Royal Challengers Bengaluru v Gujarat Titans", + "short_name": "RCB v GT", + "format": "T20", + "is_test": false, + "state": "in", + "status_summary": "RCB need 34 runs from 42 balls", + "status_detail": "In Progress", + "status_short": "RCB 122/4", + "period": 2, + "date": "2026-05-31T14:00Z", + "venue": "Narendra Modi Stadium, Ahmedabad", + "target": 156, + "teams": [ + {"order": 0, "home_away": "home", "id": "335970", "name": "Royal Challengers Bengaluru", "short_name": "RCB", "abbr": "RCB", "logo_url": null, "winner": false, "score_str": "122/4 (13/20 ov, target 156)", "innings": [{"period": 2, "runs": 122, "wickets": 4, "overs_raw": 13.0, "overs_decimal": 13.0, "balls": 78, "is_batting": true, "description": "batting"}], "records": ""}, + {"order": 1, "home_away": "away", "id": "1298769", "name": "Gujarat Titans", "short_name": "GT", "abbr": "GT", "logo_url": null, "winner": false, "score_str": "155/8 (20 ov)", "innings": [{"period": 1, "runs": 155, "wickets": 8, "overs_raw": 20.0, "overs_decimal": 20.0, "balls": 120, "is_batting": false, "description": "complete"}], "records": ""} + ] + }, + { + "id": "live-test", + "series_id": "9999", + "series_name": "Border-Gavaskar Trophy", + "competition_key": "international", + "name": "Australia v India", + "short_name": "AUS v IND", + "format": "Test", + "is_test": true, + "state": "in", + "status_summary": "India lead by 84 runs", + "status_detail": "Stumps - Day 3", + "status_short": "Stumps Day 3", + "period": 3, + "date": "2026-05-29T00:00Z", + "venue": "Melbourne Cricket Ground", + "target": null, + "teams": [ + {"order": 0, "home_away": "home", "id": "aus", "name": "Australia", "short_name": "AUS", "abbr": "AUS", "logo_url": null, "winner": false, "score_str": "295 & 180/4", "innings": [{"period": 1, "runs": 295, "wickets": 10, "overs_raw": 88.2, "overs_decimal": 88.333, "balls": 530, "is_batting": false, "description": "all out"}, {"period": 3, "runs": 180, "wickets": 4, "overs_raw": 52.0, "overs_decimal": 52.0, "balls": 312, "is_batting": true, "description": "batting"}], "records": ""}, + {"order": 1, "home_away": "away", "id": "ind", "name": "India", "short_name": "IND", "abbr": "IND", "logo_url": null, "winner": false, "score_str": "391", "innings": [{"period": 2, "runs": 391, "wickets": 10, "overs_raw": 120.4, "overs_decimal": 120.667, "balls": 724, "is_batting": false, "description": "all out"}], "records": ""} + ] + }, + { + "id": "recent-t20", + "series_id": "8048", + "series_name": "Indian Premier League", + "competition_key": "ipl", + "name": "Royal Challengers Bengaluru v Gujarat Titans", + "short_name": "RCB v GT", + "format": "T20", + "is_test": false, + "state": "post", + "status_summary": "RCB won by 5 wkts (12b rem)", + "status_detail": "Final", + "status_short": "Final", + "period": 2, + "date": "2026-05-28T14:00Z", + "venue": "M Chinnaswamy Stadium, Bengaluru", + "target": 156, + "teams": [ + {"order": 0, "home_away": "home", "id": "335970", "name": "Royal Challengers Bengaluru", "short_name": "RCB", "abbr": "RCB", "logo_url": null, "winner": true, "score_str": "161/5 (18/20 ov, target 156)", "innings": [{"period": 2, "runs": 161, "wickets": 5, "overs_raw": 18.0, "overs_decimal": 18.0, "balls": 108, "is_batting": false, "description": "target reached"}], "records": ""}, + {"order": 1, "home_away": "away", "id": "1298769", "name": "Gujarat Titans", "short_name": "GT", "abbr": "GT", "logo_url": null, "winner": false, "score_str": "155/8 (20 ov)", "innings": [{"period": 1, "runs": 155, "wickets": 8, "overs_raw": 20.0, "overs_decimal": 20.0, "balls": 120, "is_batting": false, "description": "complete"}], "records": ""} + ] + }, + { + "id": "upcoming-t20i", + "series_id": "7777", + "series_name": "India tour of England 2026", + "competition_key": "international", + "name": "England v India", + "short_name": "ENG v IND", + "format": "T20I", + "is_test": false, + "state": "pre", + "status_summary": "", + "status_detail": "Scheduled", + "status_short": "Scheduled", + "period": 0, + "date": "2026-06-05T17:30Z", + "venue": "Lord's, London", + "target": null, + "teams": [ + {"order": 0, "home_away": "home", "id": "eng", "name": "England", "short_name": "ENG", "abbr": "ENG", "logo_url": null, "winner": false, "score_str": "", "innings": [], "records": ""}, + {"order": 1, "home_away": "away", "id": "ind", "name": "India", "short_name": "IND", "abbr": "IND", "logo_url": null, "winner": false, "score_str": "", "innings": [], "records": ""} + ] + } + ] +} diff --git a/plugins/cricket-scoreboard/test_cricket_plugin.py b/plugins/cricket-scoreboard/test_cricket_plugin.py new file mode 100644 index 0000000..a82850a --- /dev/null +++ b/plugins/cricket-scoreboard/test_cricket_plugin.py @@ -0,0 +1,225 @@ +""" +Unit tests for the Cricket Scoreboard plugin. + +Focus areas: + * base-6 overs -> decimal overs conversion (the easy-to-get-wrong bit) + * current/required run-rate math + * renderer smoke tests for every format branch (live T20, live Test, + recent, upcoming) using the mocked responses in test/harness.json + * manager update/display smoke test with mock display + cache managers + +Run: python -m unittest test_cricket_plugin (needs Pillow + requests) +""" + +import json +import os +import sys +import unittest +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from cricket_data_fetcher import ( # noqa: E402 + CricketDataFetcher, + overs_to_decimal, + overs_to_balls, + current_run_rate, + required_run_rate, +) + + +def _load_mock_matches(): + with open(_HERE / "test" / "harness.json", "r", encoding="utf-8") as f: + return json.load(f)["mock_matches"] + + +class TestOversMath(unittest.TestCase): + """Base-6 fractional overs conversion and run-rate math.""" + + def test_18_4_overs_is_18_667_decimal(self): + # 18.4 overs = 18 completed overs + 4 balls = 18 + 4/6 decimal overs. + self.assertAlmostEqual(overs_to_decimal(18.4), 18 + 4 / 6, places=4) + self.assertAlmostEqual(overs_to_decimal(18.4), 18.6667, places=4) + + def test_whole_overs_unchanged(self): + self.assertAlmostEqual(overs_to_decimal(20.0), 20.0, places=6) + self.assertEqual(overs_to_decimal(0.0), 0.0) + + def test_each_ball_is_one_sixth(self): + for balls in range(6): + self.assertAlmostEqual(overs_to_decimal(12 + balls / 10.0), + 12 + balls / 6.0, places=4) + + def test_float_noise_tolerant(self): + # 18.399999 should still resolve to 4 balls, not 3. + self.assertAlmostEqual(overs_to_decimal(18.399999), 18 + 4 / 6, places=4) + + def test_malformed_fraction_rolls_over(self): + # A ".6" fraction is illegal (max 5 balls) -> roll into the next over. + self.assertAlmostEqual(overs_to_decimal(4.6), 5.0, places=4) + + def test_overs_to_balls(self): + self.assertEqual(overs_to_balls(18.4), 18 * 6 + 4) + self.assertEqual(overs_to_balls(20.0), 120) + self.assertEqual(overs_to_balls(0.0), 0) + + def test_current_run_rate(self): + # 122 runs off 13.0 overs -> 9.3846 rpo. + crr = current_run_rate(122, 13.0) + self.assertAlmostEqual(crr, 122 / 13.0, places=4) + # 100 runs off 18.4 overs uses decimal overs, NOT 18.4. + crr2 = current_run_rate(100, 18.4) + self.assertAlmostEqual(crr2, 100 / (18 + 4 / 6), places=4) + # No overs bowled -> undefined. + self.assertIsNone(current_run_rate(0, 0.0)) + + def test_required_run_rate(self): + # Chase: need 34 off 42 balls -> 34 / (42/6) = 4.857 rpo. + rrr = required_run_rate(34, 42) + self.assertAlmostEqual(rrr, 34 / (42 / 6.0), places=4) + self.assertAlmostEqual(rrr, 4.8571, places=3) + # No balls left -> undefined. + self.assertIsNone(required_run_rate(10, 0)) + + def test_parse_max_overs_and_target(self): + self.assertEqual( + CricketDataFetcher.parse_max_overs("161/5 (18/20 ov, target 156)", "T20"), + 20, + ) + # Falls back to format default when no "x/y ov" hint. + self.assertEqual(CricketDataFetcher.parse_max_overs("300/6", "ODI"), 50) + target = CricketDataFetcher._parse_target( + [{"score_str": "122/4 (13/20 ov, target 156)"}]) + self.assertEqual(target, 156) + + +class TestFormatNormalization(unittest.TestCase): + def test_formats(self): + n = CricketDataFetcher._normalize_format + self.assertEqual(n("T20"), "T20") + self.assertEqual(n("Twenty20"), "T20") + self.assertEqual(n("T20I"), "T20I") + self.assertEqual(n("ODI"), "ODI") + self.assertEqual(n("Test"), "Test") + self.assertEqual(n("First-class"), "First-class") + self.assertEqual(n(""), "T20") # defensive default + + +class TestRenderer(unittest.TestCase): + """Every format branch must render to a correctly-sized image.""" + + def setUp(self): + from cricket_renderer import CricketRenderer + self.matches = {m["id"]: m for m in _load_mock_matches()} + self.renderer = CricketRenderer(128, 32, {"show_venue": True}) + + def _check(self, image): + from PIL import Image + self.assertIsInstance(image, Image.Image) + self.assertEqual(image.size, (128, 32)) + + def test_live_limited_overs(self): + self._check(self.renderer.render_live_limited_overs(self.matches["live-t20"])) + + def test_live_test(self): + self._check(self.renderer.render_live_test(self.matches["live-test"])) + + def test_recent(self): + self._check(self.renderer.render_recent(self.matches["recent-t20"])) + + def test_upcoming(self): + self._check(self.renderer.render_upcoming(self.matches["upcoming-t20i"])) + + def test_all_sizes(self): + from cricket_renderer import CricketRenderer + for size in [(64, 32), (128, 32), (128, 64), (256, 32)]: + r = CricketRenderer(size[0], size[1], {}) + img = r.render_live_limited_overs(self.matches["live-t20"]) + self.assertEqual(img.size, size) + + +class _MockMatrix: + def __init__(self, w, h): + self.width = w + self.height = h + + +class _MockDisplayManager: + def __init__(self, w=128, h=32): + from PIL import Image + self.matrix = _MockMatrix(w, h) + self.image = Image.new("RGB", (w, h), (0, 0, 0)) + self.updated = False + + def clear(self): + from PIL import Image + self.image = Image.new("RGB", (self.matrix.width, self.matrix.height), (0, 0, 0)) + + def update_display(self): + self.updated = True + + +class _MockCacheManager: + def __init__(self): + self.store = {} + + def get(self, key, max_age=None): + return self.store.get(key) + + def set(self, key, value, ttl=None): + self.store[key] = value + + +class TestManager(unittest.TestCase): + def _make_plugin(self): + from manager import CricketScoreboardPlugin + cfg = _load_harness_config() + dm = _MockDisplayManager() + plugin = CricketScoreboardPlugin("cricket-scoreboard", cfg, dm, + _MockCacheManager(), None) + # Inject mocked matches directly, bypassing the network fetcher. + matches = _load_mock_matches() + plugin.live_matches = [m for m in matches if m["state"] == "in"] + plugin.recent_matches = [m for m in matches if m["state"] == "post"] + plugin.upcoming_matches = [m for m in matches if m["state"] == "pre"] + return plugin, dm + + def test_display_live(self): + plugin, dm = self._make_plugin() + self.assertTrue(plugin.display("cricket_live", force_clear=True)) + self.assertTrue(dm.updated) + + def test_display_recent_and_upcoming(self): + plugin, dm = self._make_plugin() + self.assertTrue(plugin.display("cricket_recent")) + self.assertTrue(plugin.display("cricket_upcoming")) + + def test_live_priority_and_content(self): + plugin, _ = self._make_plugin() + self.assertTrue(plugin.has_live_priority()) + self.assertTrue(plugin.has_live_content()) + self.assertEqual(plugin.get_live_modes(), ["cricket_live"]) + + def test_vegas_hooks(self): + plugin, _ = self._make_plugin() + self.assertEqual(plugin.get_vegas_content_type(), "multi") + cards = plugin.get_vegas_content() + self.assertTrue(cards and len(cards) >= 4) + + def test_get_cycle_duration(self): + plugin, _ = self._make_plugin() + self.assertGreater(plugin.get_cycle_duration("cricket_recent"), 0) + + def test_validate_config(self): + plugin, _ = self._make_plugin() + self.assertTrue(plugin.validate_config()) + + +def _load_harness_config(): + with open(_HERE / "test" / "harness.json", "r", encoding="utf-8") as f: + return json.load(f)["config"] + + +if __name__ == "__main__": + unittest.main(verbosity=2)