Compare commits
5 Commits
976c10c4ac
...
eb143c44fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb143c44fa | ||
|
|
275fed402e | ||
|
|
38a9c1ed1b | ||
|
|
23f0176c18 | ||
|
|
9465fcda6e |
@@ -45,3 +45,20 @@ repos:
|
||||
args: [--ignore-missing-imports, --no-error-summary]
|
||||
pass_filenames: false
|
||||
files: ^src/
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.3
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- '-r'
|
||||
- '-ll'
|
||||
- '-c'
|
||||
- 'bandit.yaml'
|
||||
- '-x'
|
||||
- './tests,./test,./venv,./.venv,./scripts/prove_security.py,./rpi-rgb-led-matrix-master'
|
||||
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.24.3
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
||||
BIN
assets/sports/ncaa_logos/CHAMPIONSHIP.png
Normal file
|
After Width: | Height: | Size: 476 B |
BIN
assets/sports/ncaa_logos/ELITE_8.png
Normal file
|
After Width: | Height: | Size: 459 B |
BIN
assets/sports/ncaa_logos/FINAL_4.png
Normal file
|
After Width: | Height: | Size: 545 B |
BIN
assets/sports/ncaa_logos/MARCH_MADNESS.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
assets/sports/ncaa_logos/ROUND_32.png
Normal file
|
After Width: | Height: | Size: 561 B |
BIN
assets/sports/ncaa_logos/ROUND_64.png
Normal file
|
After Width: | Height: | Size: 538 B |
BIN
assets/sports/ncaa_logos/SWEET_16.png
Normal file
|
After Width: | Height: | Size: 521 B |
166
docs/DEV_PREVIEW.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Dev Preview & Visual Testing
|
||||
|
||||
Tools for rapid plugin development without deploying to the RPi.
|
||||
|
||||
## Dev Preview Server
|
||||
|
||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
python scripts/dev_server.py
|
||||
# Opens at http://localhost:5001
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
python scripts/dev_server.py --port 8080 # Custom port
|
||||
python scripts/dev_server.py --extra-dir /path/to/custom-plugin # 3rd party plugins
|
||||
python scripts/dev_server.py --debug # Flask debug mode
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Select a plugin from the dropdown (auto-discovers from `plugins/` and `plugin-repos/`)
|
||||
2. The config form auto-generates from the plugin's `config_schema.json`
|
||||
3. Tweak any config value — the display preview updates automatically
|
||||
4. Toggle "Auto" off for plugins with slow `update()` calls, then click "Render" manually
|
||||
5. Use the zoom slider to scale the tiny display (128x32) up for detailed inspection
|
||||
6. Toggle the grid overlay to see individual pixel boundaries
|
||||
|
||||
### Mock Data for API-dependent Plugins
|
||||
|
||||
Many plugins fetch data from APIs (sports scores, weather, stocks). To render these locally, expand "Mock Data" and paste a JSON object with cache keys the plugin expects.
|
||||
|
||||
To find the cache keys a plugin uses, search its `manager.py` for `self.cache_manager.set(` calls.
|
||||
|
||||
Example for a sports plugin:
|
||||
```json
|
||||
{
|
||||
"football_scores": {
|
||||
"games": [
|
||||
{"home": "Eagles", "away": "Chiefs", "home_score": 24, "away_score": 21, "status": "Final"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Render Script
|
||||
|
||||
Render any plugin to a PNG image from the command line. Useful for AI-assisted development and scripted workflows.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Basic — renders with default config
|
||||
python scripts/render_plugin.py --plugin hello-world --output /tmp/hello.png
|
||||
|
||||
# Custom config
|
||||
python scripts/render_plugin.py --plugin clock-simple \
|
||||
--config '{"timezone":"America/New_York","time_format":"12h"}' \
|
||||
--output /tmp/clock.png
|
||||
|
||||
# Different display dimensions
|
||||
python scripts/render_plugin.py --plugin hello-world --width 64 --height 32 --output /tmp/small.png
|
||||
|
||||
# 3rd party plugin from a custom directory
|
||||
python scripts/render_plugin.py --plugin my-plugin --plugin-dir /path/to/repo --output /tmp/my.png
|
||||
|
||||
# With mock API data
|
||||
python scripts/render_plugin.py --plugin football-scoreboard \
|
||||
--mock-data /tmp/mock_scores.json \
|
||||
--output /tmp/football.png
|
||||
```
|
||||
|
||||
### Using with Claude Code / AI
|
||||
|
||||
Claude can run the render script, then read the output PNG (Claude is multimodal and can see images). This enables a visual feedback loop:
|
||||
|
||||
```bash
|
||||
Claude → bash: python scripts/render_plugin.py --plugin hello-world --output /tmp/render.png
|
||||
Claude → Read /tmp/render.png ← Claude sees the actual rendered display
|
||||
Claude → (makes code changes based on what it sees)
|
||||
Claude → bash: python scripts/render_plugin.py --plugin hello-world --output /tmp/render2.png
|
||||
Claude → Read /tmp/render2.png ← verifies the visual change
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VisualTestDisplayManager (for test suites)
|
||||
|
||||
A display manager that renders real pixels for use in pytest, without requiring hardware.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
|
||||
def test_my_plugin_renders_title():
|
||||
display = VisualTestDisplayManager(width=128, height=32)
|
||||
cache = MockCacheManager()
|
||||
pm = MockPluginManager()
|
||||
|
||||
plugin = MyPlugin(
|
||||
plugin_id='my-plugin',
|
||||
config={'enabled': True, 'title': 'Hello'},
|
||||
display_manager=display,
|
||||
cache_manager=cache,
|
||||
plugin_manager=pm
|
||||
)
|
||||
|
||||
plugin.update()
|
||||
plugin.display(force_clear=True)
|
||||
|
||||
# Verify pixels were drawn (not just that methods were called)
|
||||
pixels = list(display.image.getdata())
|
||||
assert any(p != (0, 0, 0) for p in pixels), "Display should not be blank"
|
||||
|
||||
# Save snapshot for manual inspection
|
||||
display.save_snapshot('/tmp/test_my_plugin.png')
|
||||
```
|
||||
|
||||
### Pytest Fixture
|
||||
|
||||
A `visual_display_manager` fixture is available in plugin tests:
|
||||
|
||||
```python
|
||||
def test_rendering(visual_display_manager):
|
||||
visual_display_manager.draw_text("Test", x=10, y=10, color=(255, 255, 255))
|
||||
assert visual_display_manager.width == 128
|
||||
pixels = list(visual_display_manager.image.getdata())
|
||||
assert any(p != (0, 0, 0) for p in pixels)
|
||||
```
|
||||
|
||||
### Key Differences from MockDisplayManager
|
||||
|
||||
| Feature | MockDisplayManager | VisualTestDisplayManager |
|
||||
|---------|-------------------|--------------------------|
|
||||
| Renders pixels | No (logs calls only) | Yes (real PIL rendering) |
|
||||
| Loads fonts | No | Yes (same fonts as production) |
|
||||
| Save to PNG | No | Yes (`save_snapshot()`) |
|
||||
| Call tracking | Yes | Yes (backwards compatible) |
|
||||
| Use case | Unit tests (method call assertions) | Visual tests, dev preview |
|
||||
|
||||
---
|
||||
|
||||
## Plugin Test Runner
|
||||
|
||||
The test runner auto-detects `plugin-repos/` for monorepo development:
|
||||
|
||||
```bash
|
||||
# Auto-detect (tries plugins/ then plugin-repos/)
|
||||
python scripts/run_plugin_tests.py
|
||||
|
||||
# Test specific plugin
|
||||
python scripts/run_plugin_tests.py --plugin clock-simple
|
||||
|
||||
# Explicit directory
|
||||
python scripts/run_plugin_tests.py --plugins-dir plugin-repos/
|
||||
|
||||
# With coverage
|
||||
python scripts/run_plugin_tests.py --coverage --verbose
|
||||
```
|
||||
138
plugin-repos/march-madness/config_schema.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "March Madness Plugin Configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the March Madness tournament display"
|
||||
},
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"title": "Tournament Leagues",
|
||||
"description": "Which NCAA tournaments to display",
|
||||
"properties": {
|
||||
"ncaam": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Men's Tournament games"
|
||||
},
|
||||
"ncaaw": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Women's Tournament games"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"title": "Favorite Teams",
|
||||
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"display_options": {
|
||||
"type": "object",
|
||||
"title": "Display Options",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"show_seeds": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show tournament seeds (1-16) next to team names"
|
||||
},
|
||||
"show_round_logos": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show round logo separators between game groups"
|
||||
},
|
||||
"highlight_upsets": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
||||
},
|
||||
"show_bracket_progress": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show which teams are still alive in each region"
|
||||
},
|
||||
"scroll_speed": {
|
||||
"type": "number",
|
||||
"default": 1.0,
|
||||
"minimum": 0.5,
|
||||
"maximum": 5.0,
|
||||
"description": "Scroll speed (pixels per frame)"
|
||||
},
|
||||
"scroll_delay": {
|
||||
"type": "number",
|
||||
"default": 0.02,
|
||||
"minimum": 0.001,
|
||||
"maximum": 0.1,
|
||||
"description": "Delay between scroll frames (seconds)"
|
||||
},
|
||||
"target_fps": {
|
||||
"type": "integer",
|
||||
"default": 120,
|
||||
"minimum": 30,
|
||||
"maximum": 200,
|
||||
"description": "Target frames per second"
|
||||
},
|
||||
"loop": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Loop the scroll continuously"
|
||||
},
|
||||
"dynamic_duration": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically adjust display duration based on content width"
|
||||
},
|
||||
"min_duration": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 10,
|
||||
"maximum": 300,
|
||||
"description": "Minimum display duration in seconds"
|
||||
},
|
||||
"max_duration": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 30,
|
||||
"maximum": 600,
|
||||
"description": "Maximum display duration in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"data_settings": {
|
||||
"type": "object",
|
||||
"title": "Data Settings",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"update_interval": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 60,
|
||||
"description": "API request timeout in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
||||
}
|
||||
910
plugin-repos/march-madness/manager.py
Normal file
@@ -0,0 +1,910 @@
|
||||
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
||||
|
||||
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
||||
round, with seeds, round logos, live scores, and upset highlighting.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
except ImportError:
|
||||
ScrollHelper = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCOREBOARD_URLS = {
|
||||
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
||||
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
||||
}
|
||||
|
||||
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
||||
|
||||
ROUND_DISPLAY_NAMES = {
|
||||
"NCG": "Championship",
|
||||
"F4": "Final Four",
|
||||
"E8": "Elite Eight",
|
||||
"S16": "Sweet Sixteen",
|
||||
"R32": "Round of 32",
|
||||
"R64": "Round of 64",
|
||||
}
|
||||
|
||||
ROUND_LOGO_FILES = {
|
||||
"NCG": "CHAMPIONSHIP.png",
|
||||
"F4": "FINAL_4.png",
|
||||
"E8": "ELITE_8.png",
|
||||
"S16": "SWEET_16.png",
|
||||
"R32": "ROUND_32.png",
|
||||
"R64": "ROUND_64.png",
|
||||
}
|
||||
|
||||
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
||||
|
||||
# Colors
|
||||
COLOR_WHITE = (255, 255, 255)
|
||||
COLOR_GOLD = (255, 215, 0)
|
||||
COLOR_GRAY = (160, 160, 160)
|
||||
COLOR_DIM = (100, 100, 100)
|
||||
COLOR_RED = (255, 60, 60)
|
||||
COLOR_GREEN = (60, 200, 60)
|
||||
COLOR_BLACK = (0, 0, 0)
|
||||
COLOR_DARK_BG = (20, 20, 20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MarchMadnessPlugin(BasePlugin):
|
||||
"""NCAA March Madness tournament bracket tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Config
|
||||
leagues_config = config.get("leagues", {})
|
||||
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
||||
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
||||
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
||||
|
||||
display_options = config.get("display_options", {})
|
||||
self.show_seeds: bool = display_options.get("show_seeds", True)
|
||||
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
||||
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
||||
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
||||
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
||||
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
||||
self.target_fps: int = display_options.get("target_fps", 120)
|
||||
self.loop: bool = display_options.get("loop", True)
|
||||
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
||||
self.min_duration: int = display_options.get("min_duration", 30)
|
||||
self.max_duration: int = display_options.get("max_duration", 300)
|
||||
if self.min_duration > self.max_duration:
|
||||
self.logger.warning(
|
||||
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
||||
)
|
||||
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
||||
|
||||
data_settings = config.get("data_settings", {})
|
||||
self.update_interval: int = data_settings.get("update_interval", 300)
|
||||
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
||||
|
||||
# Scrolling flag for display controller
|
||||
self.enable_scrolling = True
|
||||
|
||||
# State
|
||||
self.games_data: List[Dict] = []
|
||||
self.ticker_image: Optional[Image.Image] = None
|
||||
self.last_update: float = 0
|
||||
self.dynamic_duration: float = 60
|
||||
self.total_scroll_width: int = 0
|
||||
self._display_start_time: Optional[float] = None
|
||||
self._end_reached_logged: bool = False
|
||||
self._update_lock = threading.Lock()
|
||||
self._has_live_games: bool = False
|
||||
self._cached_dynamic_duration: Optional[float] = None
|
||||
self._duration_cache_time: float = 0
|
||||
|
||||
# Display dimensions
|
||||
self.display_width: int = self.display_manager.matrix.width
|
||||
self.display_height: int = self.display_manager.matrix.height
|
||||
|
||||
# HTTP session with retry
|
||||
self.session = requests.Session()
|
||||
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
||||
|
||||
# ScrollHelper
|
||||
if ScrollHelper:
|
||||
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
||||
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
||||
self.scroll_helper.set_frame_based_scrolling(True)
|
||||
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
||||
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
||||
if hasattr(self.scroll_helper, "set_target_fps"):
|
||||
self.scroll_helper.set_target_fps(self.target_fps)
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=self.dynamic_duration_enabled,
|
||||
min_duration=self.min_duration,
|
||||
max_duration=self.max_duration,
|
||||
buffer=0.1,
|
||||
)
|
||||
else:
|
||||
self.scroll_helper = None
|
||||
self.logger.warning("ScrollHelper not available")
|
||||
|
||||
# Fonts
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Logos
|
||||
self._round_logos: Dict[str, Image.Image] = {}
|
||||
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
||||
self._march_madness_logo: Optional[Image.Image] = None
|
||||
self._load_round_logos()
|
||||
|
||||
self.logger.info(
|
||||
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
||||
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fonts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||||
fonts = {}
|
||||
try:
|
||||
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
except IOError:
|
||||
fonts["score"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
except IOError:
|
||||
fonts["time"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
except IOError:
|
||||
fonts["detail"] = ImageFont.load_default()
|
||||
return fonts
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Logo loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_round_logos(self) -> None:
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
for round_key, filename in ROUND_LOGO_FILES.items():
|
||||
path = logo_dir / filename
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
# Resize to fit display height
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
||||
|
||||
# March Madness logo
|
||||
mm_path = logo_dir / "MARCH_MADNESS.png"
|
||||
try:
|
||||
img = Image.open(mm_path).convert("RGBA")
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load March Madness logo: {e}")
|
||||
except Exception:
|
||||
self.logger.exception("Unexpected error loading March Madness logo")
|
||||
|
||||
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
||||
if abbr in self._team_logo_cache:
|
||||
return self._team_logo_cache[abbr]
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
path = logo_dir / f"{abbr}.png"
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
target_h = self.display_height - 6
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
self._team_logo_cache[abbr] = img
|
||||
return img
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_tournament_window(self) -> bool:
|
||||
today = datetime.now(pytz.utc)
|
||||
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
||||
|
||||
def _fetch_tournament_data(self) -> List[Dict]:
|
||||
"""Fetch tournament games from ESPN scoreboard API."""
|
||||
all_games: List[Dict] = []
|
||||
|
||||
leagues = []
|
||||
if self.show_ncaam:
|
||||
leagues.append("ncaam")
|
||||
if self.show_ncaaw:
|
||||
leagues.append("ncaaw")
|
||||
|
||||
for league_key in leagues:
|
||||
url = SCOREBOARD_URLS.get(league_key)
|
||||
if not url:
|
||||
continue
|
||||
|
||||
cache_key = f"march_madness_{league_key}_scoreboard"
|
||||
cache_max_age = 60 if self._has_live_games else self.update_interval
|
||||
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
||||
if cached:
|
||||
all_games.extend(cached)
|
||||
continue
|
||||
|
||||
try:
|
||||
# NCAA basketball scoreboard without dates param returns current games
|
||||
params = {"limit": 1000, "groups": 100}
|
||||
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
events = data.get("events", [])
|
||||
|
||||
league_games = []
|
||||
for event in events:
|
||||
game = self._parse_event(event, league_key)
|
||||
if game:
|
||||
league_games.append(game)
|
||||
|
||||
self.cache_manager.set(cache_key, league_games)
|
||||
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
||||
all_games.extend(league_games)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(f"Error fetching {league_key} tournament data")
|
||||
|
||||
return all_games
|
||||
|
||||
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
||||
"""Parse an ESPN event into a game dict."""
|
||||
competitions = event.get("competitions", [])
|
||||
if not competitions:
|
||||
return None
|
||||
comp = competitions[0]
|
||||
|
||||
# Confirm tournament game
|
||||
comp_type = comp.get("type", {})
|
||||
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
||||
notes = comp.get("notes", [])
|
||||
headline = ""
|
||||
if notes:
|
||||
headline = notes[0].get("headline", "")
|
||||
if not is_tournament and "Championship" in headline:
|
||||
is_tournament = True
|
||||
if not is_tournament:
|
||||
return None
|
||||
|
||||
# Status
|
||||
status = comp.get("status", {}).get("type", {})
|
||||
state = status.get("state", "pre")
|
||||
status_detail = status.get("shortDetail", "")
|
||||
|
||||
# Teams
|
||||
competitors = comp.get("competitors", [])
|
||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||
if not home_team or not away_team:
|
||||
return None
|
||||
|
||||
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
||||
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Seeds
|
||||
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
||||
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
||||
if home_seed >= 99:
|
||||
home_seed = 0
|
||||
if away_seed >= 99:
|
||||
away_seed = 0
|
||||
|
||||
# Round and region
|
||||
tournament_round = self._parse_round(headline)
|
||||
tournament_region = self._parse_region(headline)
|
||||
|
||||
# Date/time
|
||||
date_str = event.get("date", "")
|
||||
start_time_utc = None
|
||||
game_date = ""
|
||||
game_time = ""
|
||||
try:
|
||||
if date_str.endswith("Z"):
|
||||
date_str = date_str.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
||||
game_date = local.strftime("%-m/%-d")
|
||||
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Period / clock for live games
|
||||
period = 0
|
||||
clock = ""
|
||||
period_text = ""
|
||||
is_halftime = False
|
||||
if state == "in":
|
||||
status_obj = comp.get("status", {})
|
||||
period = status_obj.get("period", 0)
|
||||
clock = status_obj.get("displayClock", "")
|
||||
detail_lower = status_detail.lower()
|
||||
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
||||
if period <= (4 if uses_quarters else 2):
|
||||
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
||||
else:
|
||||
ot_num = period - (4 if uses_quarters else 2)
|
||||
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
||||
if "halftime" in detail_lower:
|
||||
is_halftime = True
|
||||
elif state == "post":
|
||||
period_text = status.get("shortDetail", "Final")
|
||||
if "Final" not in period_text:
|
||||
period_text = "Final"
|
||||
|
||||
# Determine winner and upset
|
||||
is_final = state == "post"
|
||||
is_upset = False
|
||||
winner_side = ""
|
||||
if is_final:
|
||||
try:
|
||||
h = int(float(home_score))
|
||||
a = int(float(away_score))
|
||||
if h > a:
|
||||
winner_side = "home"
|
||||
if home_seed > away_seed > 0:
|
||||
is_upset = True
|
||||
elif a > h:
|
||||
winner_side = "away"
|
||||
if away_seed > home_seed > 0:
|
||||
is_upset = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": event.get("id", ""),
|
||||
"league": league_key,
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_seed": home_seed,
|
||||
"away_seed": away_seed,
|
||||
"tournament_round": tournament_round,
|
||||
"tournament_region": tournament_region,
|
||||
"state": state,
|
||||
"is_final": is_final,
|
||||
"is_live": state == "in",
|
||||
"is_upcoming": state == "pre",
|
||||
"is_halftime": is_halftime,
|
||||
"period": period,
|
||||
"period_text": period_text,
|
||||
"clock": clock,
|
||||
"status_detail": status_detail,
|
||||
"game_date": game_date,
|
||||
"game_time": game_time,
|
||||
"start_time_utc": start_time_utc,
|
||||
"is_upset": is_upset,
|
||||
"winner_side": winner_side,
|
||||
"headline": headline,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_round(headline: str) -> str:
|
||||
hl = headline.lower()
|
||||
if "national championship" in hl:
|
||||
return "NCG"
|
||||
if "final four" in hl:
|
||||
return "F4"
|
||||
if "elite 8" in hl or "elite eight" in hl:
|
||||
return "E8"
|
||||
if "sweet 16" in hl or "sweet sixteen" in hl:
|
||||
return "S16"
|
||||
if "2nd round" in hl or "second round" in hl:
|
||||
return "R32"
|
||||
if "1st round" in hl or "first round" in hl:
|
||||
return "R64"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_region(headline: str) -> str:
|
||||
if "East Region" in headline:
|
||||
return "E"
|
||||
if "West Region" in headline:
|
||||
return "W"
|
||||
if "South Region" in headline:
|
||||
return "S"
|
||||
if "Midwest Region" in headline:
|
||||
return "MW"
|
||||
m = re.search(r"Regional (\d+)", headline)
|
||||
if m:
|
||||
return f"R{m.group(1)}"
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Game processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""Group games by round, sorted by round significance then region/seed."""
|
||||
grouped: Dict[str, List[Dict]] = {}
|
||||
for game in games:
|
||||
rnd = game.get("tournament_round", "")
|
||||
grouped.setdefault(rnd, []).append(game)
|
||||
|
||||
# Sort each round's games by region then seed matchup
|
||||
for rnd, round_games in grouped.items():
|
||||
round_games.sort(
|
||||
key=lambda g: (
|
||||
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
||||
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
||||
)
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _draw_text_with_outline(
|
||||
self,
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
xy: tuple,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
fill: tuple = COLOR_WHITE,
|
||||
outline: tuple = COLOR_BLACK,
|
||||
) -> None:
|
||||
x, y = xy
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
if dx or dy:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _create_round_separator(self, round_key: str) -> Image.Image:
|
||||
"""Create a separator tile for a tournament round."""
|
||||
height = self.display_height
|
||||
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
||||
font = self.fonts["time"]
|
||||
|
||||
# Measure text
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
text_width = int(tmp_draw.textlength(name, font=font))
|
||||
|
||||
# Logo on each side
|
||||
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
||||
logo_w = logo.width if logo else 0
|
||||
padding = 6
|
||||
|
||||
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
||||
total_w = max(total_w, 80)
|
||||
|
||||
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw logos
|
||||
x = padding
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
x += logo_w + padding
|
||||
|
||||
# Draw round name
|
||||
text_y = (height - 8) // 2 # 8px font
|
||||
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
||||
x += text_width + padding
|
||||
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_game_tile(self, game: Dict) -> Image.Image:
|
||||
"""Create a single game tile for the scrolling ticker."""
|
||||
height = self.display_height
|
||||
font_score = self.fonts["score"]
|
||||
font_time = self.fonts["time"]
|
||||
font_detail = self.fonts["detail"]
|
||||
|
||||
# Load team logos
|
||||
away_logo = self._get_team_logo(game["away_abbr"])
|
||||
home_logo = self._get_team_logo(game["home_abbr"])
|
||||
logo_w = 0
|
||||
if away_logo:
|
||||
logo_w = max(logo_w, away_logo.width)
|
||||
if home_logo:
|
||||
logo_w = max(logo_w, home_logo.width)
|
||||
if logo_w == 0:
|
||||
logo_w = 24
|
||||
|
||||
# Build text elements
|
||||
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
||||
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
||||
away_text = f"{away_seed_str}{game['away_abbr']}"
|
||||
home_text = f"{game['home_abbr']}{home_seed_str}"
|
||||
|
||||
# Measure text widths
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
||||
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
||||
|
||||
# Center content: status line
|
||||
if game["is_live"]:
|
||||
if game["is_halftime"]:
|
||||
status_text = "Halftime"
|
||||
else:
|
||||
status_text = f"{game['period_text']} {game['clock']}".strip()
|
||||
elif game["is_final"]:
|
||||
status_text = game.get("period_text", "Final")
|
||||
else:
|
||||
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
||||
|
||||
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
||||
|
||||
# Score line (for live/final)
|
||||
score_text = ""
|
||||
if game["is_live"] or game["is_final"]:
|
||||
score_text = f"{game['away_score']}-{game['home_score']}"
|
||||
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
||||
|
||||
# Calculate tile width
|
||||
h_pad = 4
|
||||
center_w = max(status_w, score_w, 40)
|
||||
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
||||
|
||||
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Paste away logo
|
||||
x = h_pad
|
||||
if away_logo:
|
||||
logo_y = (height - away_logo.height) // 2
|
||||
img.paste(away_logo, (x, logo_y), away_logo)
|
||||
x += logo_w + h_pad
|
||||
|
||||
# Away team text (seed + abbr)
|
||||
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
||||
away_color = COLOR_GOLD
|
||||
team_text_y = (height - 6) // 2 - 5 # Upper half
|
||||
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
||||
x += away_text_w + h_pad
|
||||
|
||||
# Center block
|
||||
center_x = x
|
||||
center_mid = center_x + center_w // 2
|
||||
|
||||
# Status text (top center of center block)
|
||||
status_x = center_mid - status_w // 2
|
||||
status_y = 2
|
||||
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
||||
|
||||
# Score (bottom center of center block, for live/final)
|
||||
if score_text:
|
||||
score_x = center_mid - score_w // 2
|
||||
score_y = height - 13
|
||||
# Upset highlighting
|
||||
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
||||
score_color = COLOR_GOLD
|
||||
elif game["is_live"]:
|
||||
score_color = COLOR_WHITE
|
||||
else:
|
||||
score_color = COLOR_WHITE
|
||||
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
||||
|
||||
# Date for final games (below score)
|
||||
if game["is_final"] and game.get("game_date"):
|
||||
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
||||
date_x = center_mid - date_w // 2
|
||||
date_y = height - 6
|
||||
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
||||
|
||||
x = center_x + center_w + h_pad
|
||||
|
||||
# Home team text
|
||||
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
||||
home_color = COLOR_GOLD
|
||||
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
||||
x += home_text_w + h_pad
|
||||
|
||||
# Paste home logo
|
||||
if home_logo:
|
||||
logo_y = (height - home_logo.height) // 2
|
||||
img.paste(home_logo, (x, logo_y), home_logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_ticker_image(self) -> None:
|
||||
"""Build the full scrolling ticker image from game tiles."""
|
||||
if not self.games_data:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
grouped = self._process_games(self.games_data)
|
||||
content_items: List[Image.Image] = []
|
||||
|
||||
# Order rounds by significance (most important first)
|
||||
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
||||
|
||||
for rnd in sorted_rounds:
|
||||
games = grouped[rnd]
|
||||
if not games:
|
||||
continue
|
||||
|
||||
# Add round separator
|
||||
if self.show_round_logos and rnd:
|
||||
separator = self._create_round_separator(rnd)
|
||||
content_items.append(separator)
|
||||
|
||||
# Add game tiles
|
||||
for game in games:
|
||||
tile = self._create_game_tile(game)
|
||||
content_items.append(tile)
|
||||
|
||||
if not content_items:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self.ticker_image = None
|
||||
return
|
||||
|
||||
gap_width = 16
|
||||
|
||||
# Use ScrollHelper to create the scrolling image
|
||||
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_items,
|
||||
item_gap=gap_width,
|
||||
element_gap=0,
|
||||
)
|
||||
|
||||
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
self.logger.info(
|
||||
f"Ticker image created: {self.ticker_image.width}px wide, "
|
||||
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch and process tournament data."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
# Use shorter interval if live games detected
|
||||
interval = 60 if self._has_live_games else self.update_interval
|
||||
if current_time - self.last_update < interval:
|
||||
return
|
||||
|
||||
with self._update_lock:
|
||||
self.last_update = current_time
|
||||
|
||||
if not self._is_tournament_window():
|
||||
self.logger.debug("Outside tournament window, skipping fetch")
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
try:
|
||||
games = self._fetch_tournament_data()
|
||||
self._has_live_games = any(g["is_live"] for g in games)
|
||||
self.games_data = games
|
||||
self._create_ticker_image()
|
||||
self.logger.info(
|
||||
f"Updated: {len(games)} games, "
|
||||
f"live={self._has_live_games}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update error: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""Render one scroll frame."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if force_clear or self._display_start_time is None:
|
||||
self._display_start_time = time.time()
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
self._end_reached_logged = False
|
||||
|
||||
if not self.games_data or self.ticker_image is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
try:
|
||||
if self.loop or not self.scroll_helper.is_scroll_complete():
|
||||
self.scroll_helper.update_scroll_position()
|
||||
elif not self._end_reached_logged:
|
||||
self.logger.info("Scroll complete")
|
||||
self._end_reached_logged = True
|
||||
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if visible is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
matrix_w = self.display_manager.matrix.width
|
||||
matrix_h = self.display_manager.matrix.height
|
||||
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
||||
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
||||
self.display_manager.image.paste(visible, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
self.scroll_helper.log_frame_rate()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Display error: {e}", exc_info=True)
|
||||
self._display_fallback()
|
||||
|
||||
def _display_fallback(self) -> None:
|
||||
w = self.display_manager.matrix.width
|
||||
h = self.display_manager.matrix.height
|
||||
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if self._is_tournament_window():
|
||||
text = "No games"
|
||||
else:
|
||||
text = "Off-season"
|
||||
|
||||
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
||||
text_x = (w - text_w) // 2
|
||||
text_y = (h - 8) // 2
|
||||
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
||||
|
||||
# Show March Madness logo if available
|
||||
if self._march_madness_logo:
|
||||
logo_y = (h - self._march_madness_logo.height) // 2
|
||||
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
||||
|
||||
self.display_manager.image = img
|
||||
self.display_manager.update_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duration / cycle management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
current_time = time.time()
|
||||
if self._cached_dynamic_duration is not None:
|
||||
cache_age = current_time - self._duration_cache_time
|
||||
if cache_age < 5.0:
|
||||
return self._cached_dynamic_duration
|
||||
|
||||
self._cached_dynamic_duration = self.dynamic_duration
|
||||
self._duration_cache_time = current_time
|
||||
return self.dynamic_duration
|
||||
|
||||
def supports_dynamic_duration(self) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
return self.dynamic_duration_enabled
|
||||
|
||||
def is_cycle_complete(self) -> bool:
|
||||
if not self.supports_dynamic_duration():
|
||||
return True
|
||||
if self._display_start_time is not None and self.dynamic_duration > 0:
|
||||
elapsed = time.time() - self._display_start_time
|
||||
if elapsed >= self.dynamic_duration:
|
||||
return True
|
||||
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset_cycle_state(self) -> None:
|
||||
super().reset_cycle_state()
|
||||
self._display_start_time = None
|
||||
self._end_reached_logged = False
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vegas mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self):
|
||||
if not self.games_data:
|
||||
return None
|
||||
tiles = []
|
||||
for game in self.games_data:
|
||||
tiles.append(self._create_game_tile(game))
|
||||
return tiles if tiles else None
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
return "multi"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Info / cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_info(self) -> Dict:
|
||||
info = super().get_info()
|
||||
info["total_games"] = len(self.games_data)
|
||||
info["has_live_games"] = self._has_live_games
|
||||
info["dynamic_duration"] = self.dynamic_duration
|
||||
info["tournament_window"] = self._is_tournament_window()
|
||||
return info
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
self._team_logo_cache.clear()
|
||||
if self.session:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
super().cleanup()
|
||||
37
plugin-repos/march-madness/manifest.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "march-madness",
|
||||
"name": "March Madness",
|
||||
"version": "1.0.0",
|
||||
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
||||
"author": "ChuckBuilds",
|
||||
"category": "sports",
|
||||
"tags": [
|
||||
"ncaa",
|
||||
"basketball",
|
||||
"march-madness",
|
||||
"tournament",
|
||||
"bracket",
|
||||
"scrolling"
|
||||
],
|
||||
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"branch": "main",
|
||||
"plugin_path": "plugins/march-madness",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"ledmatrix_min": "2.0.0",
|
||||
"released": "2026-02-16"
|
||||
}
|
||||
],
|
||||
"stars": 0,
|
||||
"downloads": 0,
|
||||
"last_updated": "2026-02-16",
|
||||
"verified": true,
|
||||
"screenshot": "",
|
||||
"display_modes": [
|
||||
"march_madness"
|
||||
],
|
||||
"dependencies": {},
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MarchMadnessPlugin"
|
||||
}
|
||||
4
plugin-repos/march-madness/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests>=2.28.0
|
||||
Pillow>=9.1.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
302
scripts/dev_server.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LEDMatrix Dev Preview Server
|
||||
|
||||
A standalone lightweight Flask app for rapid plugin development.
|
||||
Pick a plugin, tweak its config, and instantly see the rendered display.
|
||||
|
||||
Usage:
|
||||
python scripts/dev_server.py
|
||||
python scripts/dev_server.py --port 5001
|
||||
python scripts/dev_server.py --extra-dir /path/to/custom-plugin
|
||||
|
||||
Opens at http://localhost:5001
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Prevent hardware imports
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
|
||||
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Will be set from CLI args
|
||||
_extra_dirs: List[str] = []
|
||||
|
||||
# Render endpoint resource guards
|
||||
MAX_WIDTH = 512
|
||||
MAX_HEIGHT = 512
|
||||
MIN_WIDTH = 1
|
||||
MIN_HEIGHT = 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Plugin discovery
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get_search_dirs() -> List[Path]:
|
||||
"""Get all directories to search for plugins."""
|
||||
dirs = [
|
||||
PROJECT_ROOT / 'plugins',
|
||||
PROJECT_ROOT / 'plugin-repos',
|
||||
]
|
||||
for d in _extra_dirs:
|
||||
dirs.append(Path(d))
|
||||
return dirs
|
||||
|
||||
|
||||
def discover_plugins() -> List[Dict[str, Any]]:
|
||||
"""Discover all available plugins across search directories."""
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
seen_ids: set = set()
|
||||
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
logger.debug("[Dev Server] Search dir missing, skipping: %s", search_dir)
|
||||
continue
|
||||
for item in sorted(search_dir.iterdir()):
|
||||
if item.name.startswith('.') or not item.is_dir():
|
||||
logger.debug("[Dev Server] Skipping non-plugin entry: %s", item)
|
||||
continue
|
||||
manifest_path = item / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
logger.debug("[Dev Server] No manifest.json in %s, skipping", item)
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest: Dict[str, Any] = json.load(f)
|
||||
plugin_id: str = manifest.get('id', item.name)
|
||||
if plugin_id in seen_ids:
|
||||
logger.debug("[Dev Server] Duplicate plugin_id '%s' at %s, skipping", plugin_id, item)
|
||||
continue
|
||||
seen_ids.add(plugin_id)
|
||||
logger.debug("[Dev Server] Discovered plugin id=%s name=%s", plugin_id, manifest.get('name', plugin_id))
|
||||
plugins.append({
|
||||
'id': plugin_id,
|
||||
'name': manifest.get('name', plugin_id),
|
||||
'description': manifest.get('description', ''),
|
||||
'author': manifest.get('author', ''),
|
||||
'version': manifest.get('version', ''),
|
||||
'source_dir': str(search_dir),
|
||||
'plugin_dir': str(item),
|
||||
})
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("[Dev Server] JSON decode error in %s: %s", manifest_path, e)
|
||||
continue
|
||||
except OSError as e:
|
||||
logger.warning("[Dev Server] OS error reading %s: %s", manifest_path, e)
|
||||
continue
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
"""Find a plugin directory by ID."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Routes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Serve the dev preview page."""
|
||||
return render_template('dev_preview.html')
|
||||
|
||||
|
||||
@app.route('/api/plugins')
|
||||
def api_plugins():
|
||||
"""List all available plugins."""
|
||||
return jsonify({'plugins': discover_plugins()})
|
||||
|
||||
|
||||
@app.route('/api/plugins/<plugin_id>/schema')
|
||||
def api_plugin_schema(plugin_id):
|
||||
"""Get a plugin's config_schema.json."""
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
||||
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
return jsonify({'schema': schema})
|
||||
|
||||
|
||||
@app.route('/api/plugins/<plugin_id>/defaults')
|
||||
def api_plugin_defaults(plugin_id):
|
||||
"""Get default config values from the schema."""
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
defaults = load_config_defaults(plugin_dir)
|
||||
defaults['enabled'] = True
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
@app.route('/api/render', methods=['POST'])
|
||||
def api_render():
|
||||
"""Render a plugin and return the display as base64 PNG."""
|
||||
data = request.get_json()
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
plugin_id = data['plugin_id']
|
||||
user_config = data.get('config', {})
|
||||
mock_data = data.get('mock_data', {})
|
||||
skip_update = data.get('skip_update', False)
|
||||
|
||||
try:
|
||||
width = int(data.get('width', 128))
|
||||
height = int(data.get('height', 32))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'width and height must be integers'}), 400
|
||||
|
||||
if not (MIN_WIDTH <= width <= MAX_WIDTH):
|
||||
return jsonify({'error': f'width must be between {MIN_WIDTH} and {MAX_WIDTH}'}), 400
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
# Load manifest
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config_defaults = load_config_defaults(plugin_dir)
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Create display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load plugin
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
plugin_instance, module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
warnings.append(f"update() raised: {e}")
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
errors.append(f"display() raised: {e}")
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return jsonify({
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='LEDMatrix Dev Preview Server')
|
||||
parser.add_argument('--port', type=int, default=5001, help='Port to run on (default: 5001)')
|
||||
parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)')
|
||||
parser.add_argument('--extra-dir', action='append', default=[],
|
||||
help='Extra plugin directory to search (can be repeated)')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable Flask debug mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
global _extra_dirs
|
||||
_extra_dirs = args.extra_dir
|
||||
|
||||
print(f"LEDMatrix Dev Preview Server")
|
||||
print(f"Open http://{args.host}:{args.port} in your browser")
|
||||
print(f"Plugin search dirs: {[str(d) for d in get_search_dirs()]}")
|
||||
print()
|
||||
|
||||
app.run(host=args.host, port=args.port, debug=args.debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
199
scripts/render_plugin.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Plugin Visual Renderer
|
||||
|
||||
Loads a plugin, calls update() + display(), and saves the resulting
|
||||
display as a PNG image for visual inspection.
|
||||
|
||||
Usage:
|
||||
python scripts/render_plugin.py --plugin hello-world --output /tmp/hello.png
|
||||
python scripts/render_plugin.py --plugin clock-simple --plugin-dir plugin-repos/ --output /tmp/clock.png
|
||||
python scripts/render_plugin.py --plugin hello-world --config '{"message":"Test!"}' --output /tmp/test.png
|
||||
python scripts/render_plugin.py --plugin football-scoreboard --mock-data mock_scores.json --output /tmp/football.png
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Prevent hardware imports
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
# Import logger after path setup so src.logging_config is importable
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
logger = get_logger("[Render Plugin]")
|
||||
|
||||
MIN_DIMENSION = 1
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||
"""Find a plugin directory by searching multiple paths."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in search_dirs:
|
||||
search_path = Path(search_dir)
|
||||
if not search_path.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Load and return manifest.json from plugin directory."""
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||
parser.add_argument('--plugin', '-p', required=True, help='Plugin ID to render')
|
||||
parser.add_argument('--plugin-dir', '-d', default=None,
|
||||
help='Directory to search for plugins (default: auto-detect)')
|
||||
parser.add_argument('--config', '-c', default='{}',
|
||||
help='Plugin config as JSON string')
|
||||
parser.add_argument('--mock-data', '-m', default=None,
|
||||
help='Path to JSON file with mock cache data')
|
||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png',
|
||||
help='Output PNG path (default: /tmp/plugin_render.png)')
|
||||
parser.add_argument('--width', type=int, default=128, help='Display width (default: 128)')
|
||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||
parser.add_argument('--skip-update', action='store_true',
|
||||
help='Skip calling update() (render display only)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
|
||||
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
|
||||
raise SystemExit(1)
|
||||
if not (MIN_DIMENSION <= args.height <= MAX_DIMENSION):
|
||||
print(f"Error: --height must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.height})")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Determine search directories
|
||||
if args.plugin_dir:
|
||||
search_dirs = [args.plugin_dir]
|
||||
else:
|
||||
search_dirs = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
]
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(args.plugin, search_dirs)
|
||||
if not plugin_dir:
|
||||
logger.error("Plugin '%s' not found in: %s", args.plugin, search_dirs)
|
||||
return 1
|
||||
|
||||
logger.info("Found plugin at: %s", plugin_dir)
|
||||
|
||||
# Load manifest
|
||||
manifest = load_manifest(Path(plugin_dir))
|
||||
|
||||
# Parse config: start with schema defaults, then apply overrides
|
||||
config_defaults = load_config_defaults(Path(plugin_dir))
|
||||
try:
|
||||
user_config = json.loads(args.config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON config: %s", e)
|
||||
return 1
|
||||
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Load mock data if provided
|
||||
mock_data = {}
|
||||
if args.mock_data:
|
||||
mock_data_path = Path(args.mock_data)
|
||||
if not mock_data_path.exists():
|
||||
logger.error("Mock data file not found: %s", args.mock_data)
|
||||
return 1
|
||||
with open(mock_data_path, 'r') as f:
|
||||
mock_data = json.load(f)
|
||||
|
||||
# Create visual display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=args.width, height=args.height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load and instantiate plugin
|
||||
loader = PluginLoader()
|
||||
|
||||
try:
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
plugin_id=args.plugin,
|
||||
manifest=manifest,
|
||||
plugin_dir=Path(plugin_dir),
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except (ImportError, OSError, ValueError) as e:
|
||||
logger.error("Error loading plugin '%s': %s", args.plugin, e)
|
||||
return 1
|
||||
|
||||
logger.info("Plugin '%s' loaded successfully", args.plugin)
|
||||
|
||||
# Run update() then display()
|
||||
if not args.skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
logger.debug("update() completed")
|
||||
except Exception as e:
|
||||
logger.warning("update() raised: %s — continuing to display()", e)
|
||||
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
logger.debug("display() completed")
|
||||
except Exception as e:
|
||||
logger.error("Error in display(): %s", e)
|
||||
return 1
|
||||
|
||||
# Save the rendered image
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
display_manager.save_snapshot(str(output_path))
|
||||
logger.info("Rendered image saved to: %s (%dx%d)", output_path, args.width, args.height)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -142,8 +142,8 @@ def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description='Run LEDMatrix plugin tests')
|
||||
parser.add_argument('--plugin', '-p', help='Test specific plugin ID')
|
||||
parser.add_argument('--plugins-dir', '-d', default='plugins',
|
||||
help='Plugins directory (default: plugins)')
|
||||
parser.add_argument('--plugins-dir', '-d', default=None,
|
||||
help='Plugins directory (default: auto-detect plugins/ or plugin-repos/)')
|
||||
parser.add_argument('--runner', '-r', choices=['unittest', 'pytest', 'auto'],
|
||||
default='auto', help='Test runner to use (default: auto)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true',
|
||||
@@ -153,7 +153,27 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
plugins_dir = Path(args.plugins_dir)
|
||||
if args.plugins_dir:
|
||||
plugins_dir = Path(args.plugins_dir)
|
||||
else:
|
||||
# Auto-detect: prefer plugins/ if it has content, then plugin-repos/
|
||||
plugins_path = PROJECT_ROOT / 'plugins'
|
||||
plugin_repos_path = PROJECT_ROOT / 'plugin-repos'
|
||||
try:
|
||||
has_plugins = plugins_path.exists() and any(
|
||||
p for p in plugins_path.iterdir()
|
||||
if p.is_dir() and not p.name.startswith('.')
|
||||
)
|
||||
except PermissionError:
|
||||
print(f"Warning: cannot read {plugins_path}, falling back to plugin-repos/")
|
||||
has_plugins = False
|
||||
if has_plugins:
|
||||
plugins_dir = plugins_path
|
||||
elif plugin_repos_path.exists():
|
||||
plugins_dir = plugin_repos_path
|
||||
else:
|
||||
plugins_dir = plugins_path
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Error: Plugins directory not found: {plugins_dir}")
|
||||
return 1
|
||||
|
||||
595
scripts/templates/dev_preview.html
Normal file
@@ -0,0 +1,595 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LEDMatrix Dev Preview</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: #475569;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* JSON Editor theme overrides */
|
||||
.je-object__container, .je-indented-panel {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 0.75rem !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.je-header, .je-object__title {
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
.je-form-input-label {
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
div[data-schematype] input[type="text"],
|
||||
div[data-schematype] input[type="number"],
|
||||
div[data-schematype] select,
|
||||
div[data-schematype] textarea {
|
||||
background: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0.375rem !important;
|
||||
padding: 0.375rem 0.5rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
div[data-schematype] input[type="text"]:focus,
|
||||
div[data-schematype] input[type="number"]:focus,
|
||||
div[data-schematype] select:focus,
|
||||
div[data-schematype] textarea:focus {
|
||||
outline: none !important;
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Hide JSON Editor action buttons we don't need */
|
||||
.je-object__controls .json-editor-btn-collapse,
|
||||
.je-object__controls .json-editor-btn-edit_properties,
|
||||
.json-editor-btn-edit {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.json-editor-btn-add, .json-editor-btn-delete,
|
||||
.json-editor-btn-moveup, .json-editor-btn-movedown {
|
||||
background: var(--bg-tertiary) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0.25rem !important;
|
||||
padding: 0.125rem 0.375rem !important;
|
||||
font-size: 0.7rem !important;
|
||||
}
|
||||
|
||||
/* Display preview */
|
||||
#displayPreview {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
background: repeating-conic-gradient(#1a1a2e 0% 25%, #16162a 0% 50%) 50% / 20px 20px;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Grid overlay */
|
||||
#gridCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.25rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: white;
|
||||
border-radius: 9999px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active::after {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="border-b" style="border-color: var(--border-color); background: var(--bg-secondary);">
|
||||
<div class="max-w-[1800px] mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<h1 class="text-lg font-semibold" style="color: var(--text-primary);">LEDMatrix Dev Preview</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="statusText">Ready</span>
|
||||
<button onclick="toggleTheme()" class="px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main layout -->
|
||||
<div class="max-w-[1800px] mx-auto px-4 py-4 flex gap-4" style="height: calc(100vh - 57px);">
|
||||
|
||||
<!-- Left panel: Plugin selection + Config -->
|
||||
<div class="w-[420px] flex-shrink-0 flex flex-col gap-4 overflow-y-auto" style="max-height: 100%;">
|
||||
|
||||
<!-- Plugin selector -->
|
||||
<div class="panel p-4">
|
||||
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Plugin</label>
|
||||
<select id="pluginSelect" onchange="onPluginChange()"
|
||||
class="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
<option value="">Select a plugin...</option>
|
||||
</select>
|
||||
<p id="pluginDescription" class="mt-2 text-xs" style="color: var(--text-secondary);"></p>
|
||||
</div>
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="panel p-4">
|
||||
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Display Dimensions</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input type="number" id="displayWidth" value="128" min="1" max="512"
|
||||
class="w-20 px-2 py-1.5 rounded text-sm text-center"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-sm" style="color: var(--text-secondary);">x</span>
|
||||
<input type="number" id="displayHeight" value="32" min="1" max="256"
|
||||
class="w-20 px-2 py-1.5 rounded text-sm text-center"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
<div class="panel p-4 flex-1">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="text-xs font-medium" style="color: var(--text-secondary);">Configuration</label>
|
||||
<button onclick="resetConfig()" class="px-2 py-1 rounded text-xs"
|
||||
style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div id="configEditor"></div>
|
||||
<p id="configPlaceholder" class="text-xs italic" style="color: var(--text-secondary);">
|
||||
Select a plugin to load its configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mock data -->
|
||||
<details class="panel">
|
||||
<summary class="px-4 py-3 cursor-pointer text-xs font-medium" style="color: var(--text-secondary);">
|
||||
Mock Data (for API-dependent plugins)
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<textarea id="mockDataInput" rows="6" placeholder='{"cache_key": {"data": "value"}}'
|
||||
class="w-full px-3 py-2 rounded-lg text-xs font-mono"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); resize: vertical;"
|
||||
onchange="onConfigChange()"></textarea>
|
||||
<p class="mt-1 text-xs" style="color: var(--text-secondary);">
|
||||
JSON object with cache keys. Find keys by searching plugin's manager.py for cache_manager.set() calls.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render button -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Display preview -->
|
||||
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
||||
<!-- Preview -->
|
||||
<div class="panel p-4 flex-1 flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">Display Preview</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="renderTimeText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview image -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="preview-container w-full" id="previewWrapper">
|
||||
<div style="position: relative; display: inline-block;" id="previewFrame">
|
||||
<img id="displayPreview" alt="Plugin display preview"
|
||||
style="display: none; border: 1px solid var(--border-color);">
|
||||
<canvas id="gridCanvas" style="display: none;"></canvas>
|
||||
<p id="previewPlaceholder" class="text-sm" style="color: var(--text-secondary);">
|
||||
Select a plugin and click Render to preview.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-6 mt-4 pt-3" style="border-top: 1px solid var(--border-color);">
|
||||
<!-- Zoom -->
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<label class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">Zoom</label>
|
||||
<input type="range" id="zoomSlider" min="1" max="16" value="8" step="1"
|
||||
oninput="updateZoom()" class="flex-1" style="accent-color: var(--accent);">
|
||||
<span class="text-xs w-8 text-right" style="color: var(--text-primary);" id="zoomLabel">8x</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs" for="gridToggle" style="color: var(--text-secondary);">Grid</label>
|
||||
<button role="switch" aria-checked="false" aria-label="Toggle grid overlay"
|
||||
class="toggle-switch" id="gridToggle"
|
||||
onclick="toggleGrid()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleGrid();}"></button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs" for="autoRefreshToggle" style="color: var(--text-secondary);">Auto</label>
|
||||
<button role="switch" aria-checked="true" aria-label="Toggle auto-refresh"
|
||||
class="toggle-switch active" id="autoRefreshToggle"
|
||||
onclick="toggleAutoRefresh()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleAutoRefresh();}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings/Errors -->
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---------- State ----------
|
||||
let jsonEditor = null;
|
||||
let currentPluginId = null;
|
||||
let autoRefresh = true;
|
||||
let showGrid = false;
|
||||
let debounceTimer = null;
|
||||
let currentImageWidth = 128;
|
||||
let currentImageHeight = 32;
|
||||
|
||||
// ---------- Init ----------
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Load theme
|
||||
const saved = localStorage.getItem('devPreviewTheme');
|
||||
if (saved) document.documentElement.dataset.theme = saved;
|
||||
|
||||
// Load plugins
|
||||
const res = await fetch('/api/plugins');
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('pluginSelect');
|
||||
data.plugins.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} (${p.id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Plugin selection ----------
|
||||
async function onPluginChange() {
|
||||
const pluginId = document.getElementById('pluginSelect').value;
|
||||
if (!pluginId) {
|
||||
if (jsonEditor) { jsonEditor.destroy(); jsonEditor = null; }
|
||||
document.getElementById('configPlaceholder').style.display = 'block';
|
||||
document.getElementById('pluginDescription').textContent = '';
|
||||
currentPluginId = null;
|
||||
return;
|
||||
}
|
||||
currentPluginId = pluginId;
|
||||
|
||||
// Load schema and defaults
|
||||
const [schemaRes, defaultsRes, pluginsRes] = await Promise.all([
|
||||
fetch(`/api/plugins/${pluginId}/schema`),
|
||||
fetch(`/api/plugins/${pluginId}/defaults`),
|
||||
fetch('/api/plugins'),
|
||||
]);
|
||||
|
||||
const schemaData = await schemaRes.json();
|
||||
const defaultsData = await defaultsRes.json();
|
||||
const pluginsData = await pluginsRes.json();
|
||||
|
||||
// Show description
|
||||
const plugin = pluginsData.plugins.find(p => p.id === pluginId);
|
||||
document.getElementById('pluginDescription').textContent =
|
||||
plugin ? plugin.description : '';
|
||||
|
||||
// Build config editor
|
||||
document.getElementById('configPlaceholder').style.display = 'none';
|
||||
if (jsonEditor) jsonEditor.destroy();
|
||||
|
||||
const schema = schemaData.schema || { type: 'object', properties: {} };
|
||||
|
||||
// Remove properties we don't want in the dev form
|
||||
const excluded = ['enabled', 'update_interval', 'display_duration'];
|
||||
excluded.forEach(k => { if (schema.properties) delete schema.properties[k]; });
|
||||
|
||||
jsonEditor = new JSONEditor(document.getElementById('configEditor'), {
|
||||
schema: schema,
|
||||
startval: defaultsData.defaults || {},
|
||||
theme: 'barebones',
|
||||
iconlib: null,
|
||||
disable_collapse: true,
|
||||
disable_edit_json: true,
|
||||
disable_properties: true,
|
||||
disable_array_reorder: false,
|
||||
no_additional_properties: true,
|
||||
show_errors: 'change',
|
||||
compact: true,
|
||||
});
|
||||
|
||||
jsonEditor.on('change', () => {
|
||||
if (autoRefresh) onConfigChange();
|
||||
});
|
||||
|
||||
// Auto-render on plugin change
|
||||
if (autoRefresh) renderPlugin();
|
||||
}
|
||||
|
||||
// ---------- Config change (debounced) ----------
|
||||
function onConfigChange() {
|
||||
if (!autoRefresh) return;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(renderPlugin, 500);
|
||||
}
|
||||
|
||||
function resetConfig() {
|
||||
if (!currentPluginId) return;
|
||||
onPluginChange(); // Reload defaults
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
async function renderPlugin() {
|
||||
if (!currentPluginId) return;
|
||||
|
||||
const btn = document.getElementById('renderBtn');
|
||||
const statusText = document.getElementById('statusText');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rendering...';
|
||||
statusText.textContent = 'Rendering...';
|
||||
|
||||
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||
config.enabled = true;
|
||||
|
||||
// Parse mock data
|
||||
let mockData = {};
|
||||
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||
if (mockInput) {
|
||||
try { mockData = JSON.parse(mockInput); }
|
||||
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||
}
|
||||
|
||||
const width = parseInt(document.getElementById('displayWidth').value) || 128;
|
||||
const height = parseInt(document.getElementById('displayHeight').value) || 32;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_id: currentPluginId,
|
||||
config: config,
|
||||
width: width,
|
||||
height: height,
|
||||
mock_data: mockData,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
showMessages([data.error], []);
|
||||
statusText.textContent = 'Error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update preview
|
||||
const img = document.getElementById('displayPreview');
|
||||
img.src = data.image;
|
||||
img.style.display = 'block';
|
||||
document.getElementById('previewPlaceholder').style.display = 'none';
|
||||
currentImageWidth = data.width;
|
||||
currentImageHeight = data.height;
|
||||
updateZoom();
|
||||
|
||||
// Show render time
|
||||
document.getElementById('renderTimeText').textContent =
|
||||
`${data.render_time_ms}ms`;
|
||||
|
||||
// Show warnings/errors
|
||||
showMessages(data.errors || [], data.warnings || []);
|
||||
statusText.textContent = data.errors?.length ? 'Errors' : 'Rendered';
|
||||
|
||||
} catch (e) {
|
||||
showMessages([`Network error: ${e.message}`], []);
|
||||
statusText.textContent = 'Error';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Render';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zoom ----------
|
||||
function updateZoom() {
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
document.getElementById('zoomLabel').textContent = `${zoom}x`;
|
||||
|
||||
const img = document.getElementById('displayPreview');
|
||||
if (img.style.display !== 'none') {
|
||||
img.style.width = `${currentImageWidth * zoom}px`;
|
||||
img.style.height = `${currentImageHeight * zoom}px`;
|
||||
}
|
||||
updateGrid();
|
||||
}
|
||||
|
||||
// ---------- Grid overlay ----------
|
||||
function toggleGrid() {
|
||||
showGrid = !showGrid;
|
||||
const btn = document.getElementById('gridToggle');
|
||||
btn.classList.toggle('active', showGrid);
|
||||
btn.setAttribute('aria-checked', showGrid ? 'true' : 'false');
|
||||
updateGrid();
|
||||
}
|
||||
|
||||
function updateGrid() {
|
||||
const canvas = document.getElementById('gridCanvas');
|
||||
const img = document.getElementById('displayPreview');
|
||||
|
||||
if (!showGrid || img.style.display === 'none') {
|
||||
canvas.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
const w = currentImageWidth * zoom;
|
||||
const h = currentImageHeight * zoom;
|
||||
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
// Vertical lines
|
||||
for (let x = 0; x <= currentImageWidth; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * zoom, 0);
|
||||
ctx.lineTo(x * zoom, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = 0; y <= currentImageHeight; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * zoom);
|
||||
ctx.lineTo(w, y * zoom);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Auto-refresh toggle ----------
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh = !autoRefresh;
|
||||
const btn = document.getElementById('autoRefreshToggle');
|
||||
btn.classList.toggle('active', autoRefresh);
|
||||
btn.setAttribute('aria-checked', autoRefresh ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// ---------- Theme ----------
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const current = html.dataset.theme || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
html.dataset.theme = next;
|
||||
localStorage.setItem('devPreviewTheme', next);
|
||||
}
|
||||
|
||||
// ---------- Messages ----------
|
||||
function showMessages(errors, warnings) {
|
||||
const panel = document.getElementById('messagesPanel');
|
||||
const list = document.getElementById('messagesList');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!errors.length && !warnings.length) {
|
||||
panel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
panel.classList.remove('hidden');
|
||||
errors.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'text-red-400';
|
||||
div.textContent = `Error: ${msg}`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
warnings.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'text-yellow-400';
|
||||
div.textContent = `Warning: ${msg}`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -255,12 +255,19 @@ class ScrollHelper:
|
||||
self.scroll_position += pixels_to_move
|
||||
self.total_distance_scrolled += pixels_to_move
|
||||
|
||||
# Calculate required total distance: total_scroll_width + display_width
|
||||
# The image already includes display_width padding at the start, so we need
|
||||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
||||
# more pixels to ensure the last content scrolls completely off the screen
|
||||
required_total_distance = self.total_scroll_width + self.display_width
|
||||
|
||||
# Calculate required total distance: total_scroll_width only.
|
||||
# The image already includes display_width pixels of blank padding at the start
|
||||
# (added by create_scrolling_image), so once scroll_position reaches
|
||||
# total_scroll_width the last card has fully scrolled off the left edge.
|
||||
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
|
||||
required_total_distance = self.total_scroll_width
|
||||
|
||||
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
|
||||
# completion/wrap logic to avoid producing an invalid -1 position.
|
||||
if required_total_distance == 0:
|
||||
self.scroll_position = 0
|
||||
return
|
||||
|
||||
# Check completion FIRST (before wrap-around) to prevent visual loop
|
||||
# When dynamic duration is enabled and cycle is complete, stop at end instead of wrapping
|
||||
is_complete = self.total_distance_scrolled >= required_total_distance
|
||||
|
||||
@@ -6,6 +6,7 @@ with special support for FCS teams and other NCAA divisions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
@@ -146,6 +147,9 @@ class LogoDownloader:
|
||||
|
||||
return variations
|
||||
|
||||
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
|
||||
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
||||
|
||||
def get_logo_directory(self, league: str) -> str:
|
||||
"""Get the logo directory for a given league."""
|
||||
directory = LogoDownloader.LOGO_DIRECTORIES.get(league)
|
||||
@@ -154,6 +158,10 @@ class LogoDownloader:
|
||||
if league.startswith('soccer_'):
|
||||
directory = 'assets/sports/soccer_logos'
|
||||
else:
|
||||
# Validate league before using it in a filesystem path
|
||||
if not self._SAFE_LEAGUE_RE.match(league):
|
||||
logger.warning(f"Rejecting unsafe league name for directory construction: {league!r}")
|
||||
raise ValueError(f"Unsafe league name: {league!r}")
|
||||
directory = f'assets/sports/{league}_logos'
|
||||
path = Path(directory)
|
||||
if not path.is_absolute():
|
||||
@@ -244,11 +252,17 @@ class LogoDownloader:
|
||||
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
|
||||
return False
|
||||
|
||||
# Allowlist for the league_code segment interpolated into ESPN API URLs
|
||||
_SAFE_LEAGUE_CODE_RE = re.compile(r'^[a-z0-9_-]+$')
|
||||
|
||||
def _resolve_api_url(self, league: str) -> Optional[str]:
|
||||
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
|
||||
api_url = self.API_ENDPOINTS.get(league)
|
||||
if not api_url and league.startswith('soccer_'):
|
||||
league_code = league[len('soccer_'):]
|
||||
if not self._SAFE_LEAGUE_CODE_RE.match(league_code):
|
||||
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
|
||||
return None
|
||||
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
||||
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
||||
return api_url
|
||||
|
||||
@@ -1756,10 +1756,23 @@ class PluginStoreManager:
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
self.logger.error(f"Plugin not installed: {plugin_id}")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
self.logger.info(f"Checking for updates to plugin {plugin_id}")
|
||||
|
||||
|
||||
# Check if this is a bundled/unmanaged plugin (no registry entry, no git remote)
|
||||
# These are plugins shipped with LEDMatrix itself and updated via LEDMatrix updates.
|
||||
metadata_path = plugin_path / ".plugin_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
if metadata.get('install_type') == 'bundled':
|
||||
self.logger.info(f"Plugin {plugin_id} is a bundled plugin; updates are delivered via LEDMatrix itself")
|
||||
return True
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.debug(f"[PluginStore] Could not read metadata for {plugin_id} at {metadata_path}: {e}")
|
||||
|
||||
# First check if it's a git repository - if so, we can update directly
|
||||
git_info = self._get_local_git_info(plugin_path)
|
||||
|
||||
@@ -2026,8 +2039,10 @@ class PluginStoreManager:
|
||||
# (in case .git directory was removed but remote URL is still in config)
|
||||
repo_url = None
|
||||
try:
|
||||
# Use --local to avoid inheriting the parent LEDMatrix repo's git config
|
||||
# when the plugin directory lives inside the main repo (e.g. plugin-repos/).
|
||||
remote_url_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'config', '--get', 'remote.origin.url'],
|
||||
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
|
||||
@@ -6,12 +6,14 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
||||
|
||||
from .plugin_test_base import PluginTestCase
|
||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||
from .visual_display_manager import VisualTestDisplayManager
|
||||
|
||||
__all__ = [
|
||||
'PluginTestCase',
|
||||
'VisualTestDisplayManager',
|
||||
'MockDisplayManager',
|
||||
'MockCacheManager',
|
||||
'MockConfigManager',
|
||||
'MockPluginManager'
|
||||
'MockPluginManager',
|
||||
]
|
||||
|
||||
|
||||
514
src/plugin_system/testing/visual_display_manager.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Visual Test Display Manager for LEDMatrix.
|
||||
|
||||
A display manager that performs real pixel rendering using PIL,
|
||||
without requiring hardware or the RGBMatrixEmulator. Used for:
|
||||
- Local dev preview server
|
||||
- CLI render script (AI visual feedback)
|
||||
- Visual assertions in pytest
|
||||
|
||||
Unlike MockDisplayManager (which logs calls but doesn't render) or
|
||||
MagicMock (which tracks nothing visual), this class creates a real
|
||||
PIL Image canvas and draws text using the actual project fonts.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _MatrixProxy:
|
||||
"""Lightweight proxy so plugins can access display_manager.matrix.width/height."""
|
||||
|
||||
def __init__(self, width: int, height: int):
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
|
||||
class VisualTestDisplayManager:
|
||||
"""
|
||||
Display manager that renders real pixels for testing and development.
|
||||
|
||||
Implements the same interface that plugins expect from DisplayManager,
|
||||
but operates entirely in-memory with PIL — no hardware, no singleton,
|
||||
no emulator dependency.
|
||||
"""
|
||||
|
||||
# Weather icon color constants (same as DisplayManager)
|
||||
WEATHER_COLORS = {
|
||||
'sun': (255, 200, 0),
|
||||
'cloud': (200, 200, 200),
|
||||
'rain': (0, 100, 255),
|
||||
'snow': (220, 220, 255),
|
||||
'storm': (255, 255, 0),
|
||||
}
|
||||
|
||||
def __init__(self, width: int = 128, height: int = 32):
|
||||
self._width = width
|
||||
self._height = height
|
||||
|
||||
# Canvas
|
||||
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
|
||||
# Scrolling state (interface compat, no-op)
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
'last_scroll_activity': 0,
|
||||
'scroll_inactivity_threshold': 2.0,
|
||||
'deferred_updates': [],
|
||||
'max_deferred_updates': 50,
|
||||
'deferred_update_ttl': 300.0,
|
||||
}
|
||||
|
||||
# Call tracking (preserves MockDisplayManager capabilities)
|
||||
self.clear_called = False
|
||||
self.update_called = False
|
||||
self.draw_calls = []
|
||||
|
||||
# Load fonts
|
||||
self._load_fonts()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.image.width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.image.height
|
||||
|
||||
@property
|
||||
def display_width(self) -> int:
|
||||
return self.image.width
|
||||
|
||||
@property
|
||||
def display_height(self) -> int:
|
||||
return self.image.height
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Font loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_project_root(self) -> Optional[Path]:
|
||||
"""Walk up from this file to find the project root (contains assets/fonts)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for _ in range(10):
|
||||
if (current / 'assets' / 'fonts').exists():
|
||||
return current
|
||||
current = current.parent
|
||||
return None
|
||||
|
||||
def _load_fonts(self):
|
||||
"""Load fonts with graceful fallback, matching DisplayManager._load_fonts()."""
|
||||
project_root = self._find_project_root()
|
||||
|
||||
try:
|
||||
if project_root is None:
|
||||
raise FileNotFoundError("Could not find project root with assets/fonts")
|
||||
|
||||
fonts_dir = project_root / 'assets' / 'fonts'
|
||||
|
||||
# Press Start 2P — regular and small (both 8px)
|
||||
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
|
||||
self.regular_font = ImageFont.truetype(ttf_path, 8)
|
||||
self.small_font = ImageFont.truetype(ttf_path, 8)
|
||||
self.font = self.regular_font # alias used by some code paths
|
||||
|
||||
# 5x7 BDF font via freetype
|
||||
try:
|
||||
import freetype
|
||||
bdf_path = str(fonts_dir / '5x7.bdf')
|
||||
if not os.path.exists(bdf_path):
|
||||
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
|
||||
face = freetype.Face(bdf_path)
|
||||
self.calendar_font = face
|
||||
self.bdf_5x7_font = face
|
||||
except (ImportError, FileNotFoundError, OSError) as e:
|
||||
logger.debug("BDF font not available, using small_font as fallback: %s", e)
|
||||
self.calendar_font = self.small_font
|
||||
self.bdf_5x7_font = self.small_font
|
||||
|
||||
# 4x6 extra small TTF
|
||||
try:
|
||||
xs_path = str(fonts_dir / '4x6-font.ttf')
|
||||
self.extra_small_font = ImageFont.truetype(xs_path, 6)
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
logger.debug("Extra small font not available, using fallback: %s", e)
|
||||
self.extra_small_font = self.small_font
|
||||
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
logger.debug("Font loading fallback: %s", e)
|
||||
self.regular_font = ImageFont.load_default()
|
||||
self.small_font = self.regular_font
|
||||
self.font = self.regular_font
|
||||
self.calendar_font = self.regular_font
|
||||
self.bdf_5x7_font = self.regular_font
|
||||
self.extra_small_font = self.regular_font
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core display methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def clear(self):
|
||||
"""Clear the display to black."""
|
||||
self.clear_called = True
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
|
||||
def update_display(self):
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
self.update_called = True
|
||||
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||
"""Draw text on the canvas, matching DisplayManager.draw_text() signature."""
|
||||
# Track the call
|
||||
self.draw_calls.append({
|
||||
'type': 'text', 'text': text, 'x': x, 'y': y,
|
||||
'color': color, 'font': font,
|
||||
})
|
||||
|
||||
try:
|
||||
# Normalize color to tuple (plugins may pass lists from JSON config)
|
||||
if isinstance(color, list):
|
||||
color = tuple(color)
|
||||
|
||||
# Select font
|
||||
if font:
|
||||
current_font = font
|
||||
else:
|
||||
current_font = self.small_font if small_font else self.regular_font
|
||||
|
||||
# Calculate x position
|
||||
if x is None:
|
||||
text_width = self.get_text_width(text, current_font)
|
||||
x = (self.width - text_width) // 2
|
||||
elif centered:
|
||||
text_width = self.get_text_width(text, current_font)
|
||||
x = x - (text_width // 2)
|
||||
|
||||
if y is None:
|
||||
y = 0
|
||||
|
||||
# Draw
|
||||
try:
|
||||
import freetype
|
||||
is_bdf = isinstance(current_font, freetype.Face)
|
||||
except ImportError:
|
||||
is_bdf = False
|
||||
|
||||
if is_bdf:
|
||||
self._draw_bdf_text(text, x, y, color, current_font)
|
||||
else:
|
||||
self.draw.text((x, y), text, font=current_font, fill=color)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error drawing text: {e}")
|
||||
|
||||
def draw_image(self, image: Image.Image, x: int, y: int):
|
||||
"""Draw an image on the display."""
|
||||
self.draw_calls.append({
|
||||
'type': 'image', 'image': image, 'x': x, 'y': y,
|
||||
})
|
||||
try:
|
||||
self.image.paste(image, (x, y))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error drawing image: {e}")
|
||||
|
||||
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
|
||||
"""Draw text using BDF font with proper bitmap handling.
|
||||
|
||||
Replicated from DisplayManager._draw_bdf_text().
|
||||
"""
|
||||
try:
|
||||
import freetype
|
||||
if isinstance(color, list):
|
||||
color = tuple(color)
|
||||
face = font if font else self.calendar_font
|
||||
|
||||
# Compute baseline from font ascender
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer):
|
||||
byte = bitmap.buffer[byte_index]
|
||||
if byte & (1 << (7 - (j % 8))):
|
||||
pixel_x = x + glyph_left + j
|
||||
pixel_y = baseline_y - glyph_top + i
|
||||
if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
|
||||
self.draw.point((pixel_x, pixel_y), fill=color)
|
||||
|
||||
x += face.glyph.advance.x >> 6
|
||||
except Exception as e:
|
||||
logger.debug(f"Error drawing BDF text: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text measurement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_text_width(self, text: str, font=None) -> int:
|
||||
"""Get text width in pixels, matching DisplayManager.get_text_width()."""
|
||||
if font is None:
|
||||
font = self.regular_font
|
||||
try:
|
||||
try:
|
||||
import freetype
|
||||
is_bdf = isinstance(font, freetype.Face)
|
||||
except ImportError:
|
||||
is_bdf = False
|
||||
|
||||
if is_bdf:
|
||||
width = 0
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6
|
||||
return width
|
||||
else:
|
||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_font_height(self, font=None) -> int:
|
||||
"""Get font height in pixels, matching DisplayManager.get_font_height()."""
|
||||
if font is None:
|
||||
font = self.regular_font
|
||||
try:
|
||||
try:
|
||||
import freetype
|
||||
is_bdf = isinstance(font, freetype.Face)
|
||||
except ImportError:
|
||||
is_bdf = False
|
||||
|
||||
if is_bdf:
|
||||
return font.size.height >> 6
|
||||
else:
|
||||
ascent, descent = font.getmetrics()
|
||||
return ascent + descent
|
||||
except Exception:
|
||||
if hasattr(font, 'size'):
|
||||
return font.size
|
||||
return 8
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Weather drawing helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def draw_sun(self, x: int, y: int, size: int = 16):
|
||||
"""Draw a sun icon using yellow circles and lines."""
|
||||
self._draw_sun(x, y, size)
|
||||
|
||||
def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
self._draw_cloud(x, y, size, color)
|
||||
|
||||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
self._draw_rain(x, y, size)
|
||||
|
||||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
self._draw_snow(x, y, size)
|
||||
|
||||
def _draw_sun(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a sun icon with rays (internal weather icon version)."""
|
||||
center_x, center_y = x + size // 2, y + size // 2
|
||||
radius = size // 4
|
||||
ray_length = size // 3
|
||||
self.draw.ellipse(
|
||||
[center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius],
|
||||
fill=self.WEATHER_COLORS['sun'],
|
||||
)
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center_x + int((radius + 2) * math.cos(rad))
|
||||
start_y = center_y + int((radius + 2) * math.sin(rad))
|
||||
end_x = center_x + int((radius + ray_length) * math.cos(rad))
|
||||
end_y = center_y + int((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y], fill=self.WEATHER_COLORS['sun'], width=2)
|
||||
|
||||
def _draw_cloud(self, x: int, y: int, size: int, color: Optional[Tuple[int, int, int]] = None) -> None:
|
||||
"""Draw a cloud using multiple circles (internal weather icon version)."""
|
||||
cloud_color = color if color is not None else self.WEATHER_COLORS['cloud']
|
||||
base_y = y + size // 2
|
||||
circle_radius = size // 4
|
||||
positions = [
|
||||
(x + size // 3, base_y),
|
||||
(x + size // 2, base_y - size // 6),
|
||||
(x + 2 * size // 3, base_y),
|
||||
]
|
||||
for cx, cy in positions:
|
||||
self.draw.ellipse(
|
||||
[cx - circle_radius, cy - circle_radius,
|
||||
cx + circle_radius, cy + circle_radius],
|
||||
fill=cloud_color,
|
||||
)
|
||||
|
||||
def _draw_rain(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw rain drops falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
rain_color = self.WEATHER_COLORS['rain']
|
||||
drop_size = size // 8
|
||||
drops = [
|
||||
(x + size // 4, y + 2 * size // 3),
|
||||
(x + size // 2, y + 3 * size // 4),
|
||||
(x + 3 * size // 4, y + 2 * size // 3),
|
||||
]
|
||||
for dx, dy in drops:
|
||||
self.draw.line([dx, dy, dx - drop_size // 2, dy + drop_size], fill=rain_color, width=2)
|
||||
|
||||
def _draw_snow(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw snowflakes falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
snow_color = self.WEATHER_COLORS['snow']
|
||||
flake_size = size // 6
|
||||
flakes = [
|
||||
(x + size // 4, y + 2 * size // 3),
|
||||
(x + size // 2, y + 3 * size // 4),
|
||||
(x + 3 * size // 4, y + 2 * size // 3),
|
||||
]
|
||||
for fx, fy in flakes:
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = fx + int(flake_size * math.cos(rad))
|
||||
end_y = fy + int(flake_size * math.sin(rad))
|
||||
self.draw.line([fx, fy, end_x, end_y], fill=snow_color, width=1)
|
||||
|
||||
def _draw_storm(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a storm cloud with lightning bolt."""
|
||||
self._draw_cloud(x, y, size)
|
||||
bolt_color = self.WEATHER_COLORS['storm']
|
||||
bolt_points = [
|
||||
(x + size // 2, y + size // 2),
|
||||
(x + 3 * size // 5, y + 2 * size // 3),
|
||||
(x + 2 * size // 5, y + 2 * size // 3),
|
||||
(x + size // 2, y + 5 * size // 6),
|
||||
]
|
||||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||||
|
||||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||||
"""Draw a weather icon based on the condition."""
|
||||
cond = condition.lower()
|
||||
if cond in ('clear', 'sunny'):
|
||||
self._draw_sun(x, y, size)
|
||||
elif cond in ('clouds', 'cloudy', 'partly cloudy'):
|
||||
self._draw_cloud(x, y, size)
|
||||
elif cond in ('rain', 'drizzle', 'shower'):
|
||||
self._draw_rain(x, y, size)
|
||||
elif cond in ('snow', 'sleet', 'hail'):
|
||||
self._draw_snow(x, y, size)
|
||||
elif cond in ('thunderstorm', 'storm'):
|
||||
self._draw_storm(x, y, size)
|
||||
else:
|
||||
self._draw_sun(x, y, size)
|
||||
|
||||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None,
|
||||
x: int = None, y: int = None,
|
||||
color: tuple = (255, 255, 255)):
|
||||
"""Draw text with weather icons at specified positions."""
|
||||
self.draw_text(text, x, y, color)
|
||||
if icons:
|
||||
for icon_type, icon_x, icon_y in icons:
|
||||
self.draw_weather_icon(icon_type, icon_x, icon_y)
|
||||
self.update_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scrolling state (no-op interface compat)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_scrolling_state(self, is_scrolling: bool):
|
||||
"""Set the current scrolling state (no-op for testing)."""
|
||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||
if is_scrolling:
|
||||
self._scrolling_state['last_scroll_activity'] = time.time()
|
||||
|
||||
def is_currently_scrolling(self) -> bool:
|
||||
"""Check if display is currently scrolling."""
|
||||
return self._scrolling_state['is_scrolling']
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def format_date_with_ordinal(self, dt):
|
||||
"""Formats a datetime object into 'Mon Aug 30th' style."""
|
||||
day = dt.day
|
||||
if 11 <= day <= 13:
|
||||
suffix = 'th'
|
||||
else:
|
||||
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
|
||||
return dt.strftime(f"%b %-d{suffix}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Snapshot / image capture
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_snapshot(self, path: str) -> None:
|
||||
"""Save the current display as a PNG image."""
|
||||
self.image.save(path, format='PNG')
|
||||
|
||||
def get_image(self) -> Image.Image:
|
||||
"""Return the current display image."""
|
||||
return self.image
|
||||
|
||||
def get_image_base64(self) -> str:
|
||||
"""Return the current display as a base64-encoded PNG string."""
|
||||
import base64
|
||||
import io
|
||||
buffer = io.BytesIO()
|
||||
self.image.save(buffer, format='PNG')
|
||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup / reset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def reset(self):
|
||||
"""Reset all tracking state (for test reuse)."""
|
||||
self.clear_called = False
|
||||
self.update_called = False
|
||||
self.draw_calls = []
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
'last_scroll_activity': 0,
|
||||
'scroll_inactivity_threshold': 2.0,
|
||||
'deferred_updates': [],
|
||||
'max_deferred_updates': 50,
|
||||
'deferred_update_ttl': 300.0,
|
||||
}
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
@@ -8,7 +8,7 @@ import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from typing import Dict, Any
|
||||
from typing import Any, Dict, Generator, Optional
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
@@ -20,13 +20,33 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir():
|
||||
"""Get the plugins directory path."""
|
||||
return project_root / 'plugins'
|
||||
def plugins_dir() -> Path:
|
||||
"""Get the plugins directory path.
|
||||
|
||||
Checks plugins/ first, then falls back to plugin-repos/
|
||||
for monorepo development environments.
|
||||
"""
|
||||
plugins_path = project_root / 'plugins'
|
||||
plugin_repos_path = project_root / 'plugin-repos'
|
||||
|
||||
# Prefer plugins/ if it has actual plugin directories
|
||||
if plugins_path.exists():
|
||||
try:
|
||||
has_plugins = any(
|
||||
p for p in plugins_path.iterdir()
|
||||
if p.is_dir() and not p.name.startswith('.')
|
||||
)
|
||||
if has_plugins:
|
||||
return plugins_path
|
||||
except PermissionError:
|
||||
pass
|
||||
if plugin_repos_path.exists():
|
||||
return plugin_repos_path
|
||||
return plugins_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_display_manager():
|
||||
def mock_display_manager() -> Any:
|
||||
"""Create a mock DisplayManager for plugin tests."""
|
||||
mock = MagicMock()
|
||||
mock.width = 128
|
||||
@@ -44,7 +64,7 @@ def mock_display_manager():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache_manager():
|
||||
def mock_cache_manager() -> Any:
|
||||
"""Create a mock CacheManager for plugin tests."""
|
||||
mock = MagicMock()
|
||||
mock._memory_cache = {}
|
||||
@@ -68,7 +88,7 @@ def mock_cache_manager():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plugin_manager():
|
||||
def mock_plugin_manager() -> Any:
|
||||
"""Create a mock PluginManager for plugin tests."""
|
||||
mock = MagicMock()
|
||||
mock.plugins = {}
|
||||
@@ -77,7 +97,7 @@ def mock_plugin_manager():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_plugin_config():
|
||||
def base_plugin_config() -> Dict[str, Any]:
|
||||
"""Base configuration for plugins."""
|
||||
return {
|
||||
'enabled': True,
|
||||
@@ -102,3 +122,10 @@ def get_plugin_config_schema(plugin_id: str, plugins_dir: Path) -> Dict[str, Any
|
||||
with open(schema_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def visual_display_manager() -> Any:
|
||||
"""Create a VisualTestDisplayManager that renders real pixels for visual testing."""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager
|
||||
return VisualTestDisplayManager(width=128, height=32)
|
||||
|
||||
228
test/plugins/test_visual_rendering.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Tests for VisualTestDisplayManager.
|
||||
|
||||
Verifies that the visual display manager actually renders pixels,
|
||||
loads fonts, and can save snapshots.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing import VisualTestDisplayManager
|
||||
|
||||
|
||||
class TestVisualDisplayManager:
|
||||
"""Test VisualTestDisplayManager pixel rendering."""
|
||||
|
||||
def test_creates_image_with_correct_dimensions(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
assert vdm.image.size == (128, 32)
|
||||
|
||||
def test_creates_image_custom_dimensions(self):
|
||||
vdm = VisualTestDisplayManager(width=64, height=64)
|
||||
assert vdm.image.size == (64, 64)
|
||||
assert vdm.width == 64
|
||||
assert vdm.height == 64
|
||||
|
||||
def test_draw_text_renders_pixels(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Hello", x=0, y=0, color=(255, 255, 255))
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0, "draw_text should render actual pixels"
|
||||
|
||||
def test_draw_text_centered(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Test", color=(255, 0, 0)) # x=None centers text
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_text_with_centered_flag(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("X", x=64, y=10, centered=True, color=(0, 255, 0))
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_text_tracks_calls(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Hello", x=10, y=5, color=(255, 0, 0))
|
||||
assert len(vdm.draw_calls) == 1
|
||||
assert vdm.draw_calls[0]['type'] == 'text'
|
||||
assert vdm.draw_calls[0]['text'] == 'Hello'
|
||||
assert vdm.draw_calls[0]['x'] == 10
|
||||
assert vdm.draw_calls[0]['y'] == 5
|
||||
|
||||
def test_clear_resets_canvas(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Hello", x=0, y=0, color=(255, 255, 255))
|
||||
vdm.clear()
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) == 0, "clear() should reset all pixels to black"
|
||||
assert vdm.clear_called is True
|
||||
|
||||
def test_update_display_sets_flag(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
assert vdm.update_called is False
|
||||
vdm.update_display()
|
||||
assert vdm.update_called is True
|
||||
|
||||
def test_matrix_proxy(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
assert vdm.matrix.width == 128
|
||||
assert vdm.matrix.height == 32
|
||||
|
||||
def test_width_height_properties(self):
|
||||
vdm = VisualTestDisplayManager(width=64, height=32)
|
||||
assert vdm.width == 64
|
||||
assert vdm.height == 32
|
||||
assert vdm.display_width == 64
|
||||
assert vdm.display_height == 32
|
||||
|
||||
def test_save_snapshot(self, tmp_path):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Test", x=10, y=10, color=(255, 0, 0))
|
||||
output = tmp_path / "test_render.png"
|
||||
vdm.save_snapshot(str(output))
|
||||
assert output.exists()
|
||||
with Image.open(str(output)) as saved_img:
|
||||
assert saved_img.size == (128, 32)
|
||||
|
||||
def test_get_image(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
img = vdm.get_image()
|
||||
assert isinstance(img, Image.Image)
|
||||
assert img.size == (128, 32)
|
||||
|
||||
def test_get_image_base64(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Hi", x=0, y=0, color=(255, 255, 255))
|
||||
b64 = vdm.get_image_base64()
|
||||
assert isinstance(b64, str)
|
||||
assert len(b64) > 0
|
||||
# Should be valid base64 PNG
|
||||
import base64
|
||||
decoded = base64.b64decode(b64)
|
||||
assert decoded[:4] == b'\x89PNG'
|
||||
|
||||
def test_font_attributes_exist(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
assert hasattr(vdm, 'regular_font')
|
||||
assert hasattr(vdm, 'small_font')
|
||||
assert hasattr(vdm, 'extra_small_font')
|
||||
assert hasattr(vdm, 'calendar_font')
|
||||
assert hasattr(vdm, 'bdf_5x7_font')
|
||||
assert hasattr(vdm, 'font')
|
||||
|
||||
def test_get_text_width(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
w = vdm.get_text_width("Hello", vdm.regular_font)
|
||||
assert isinstance(w, int)
|
||||
assert w > 0
|
||||
|
||||
def test_get_font_height(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
h = vdm.get_font_height(vdm.regular_font)
|
||||
assert isinstance(h, int)
|
||||
assert h > 0
|
||||
|
||||
def test_image_paste(self):
|
||||
"""Verify plugins can paste images onto the display."""
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
overlay = Image.new('RGB', (10, 10), (255, 0, 0))
|
||||
vdm.image.paste(overlay, (0, 0))
|
||||
pixel = vdm.image.getpixel((5, 5))
|
||||
assert pixel == (255, 0, 0)
|
||||
|
||||
def test_image_assignment(self):
|
||||
"""Verify plugins can assign a new image to display_manager.image."""
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
new_img = Image.new('RGB', (128, 32), (0, 255, 0))
|
||||
vdm.image = new_img
|
||||
assert vdm.image.getpixel((0, 0)) == (0, 255, 0)
|
||||
|
||||
def test_draw_image(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
overlay = Image.new('RGB', (10, 10), (0, 0, 255))
|
||||
vdm.draw_image(overlay, 5, 5)
|
||||
assert len(vdm.draw_calls) == 1
|
||||
assert vdm.draw_calls[0]['type'] == 'image'
|
||||
# Verify pixels were actually pasted
|
||||
pixel = vdm.image.getpixel((7, 7))
|
||||
assert pixel == (0, 0, 255)
|
||||
|
||||
def test_reset(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_text("Hi", x=0, y=0)
|
||||
vdm.clear()
|
||||
vdm.update_display()
|
||||
vdm.reset()
|
||||
assert vdm.clear_called is False
|
||||
assert vdm.update_called is False
|
||||
assert len(vdm.draw_calls) == 0
|
||||
pixels = list(vdm.image.getdata())
|
||||
assert all(p == (0, 0, 0) for p in pixels)
|
||||
|
||||
def test_scrolling_state(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
assert vdm.is_currently_scrolling() is False
|
||||
vdm.set_scrolling_state(True)
|
||||
assert vdm.is_currently_scrolling() is True
|
||||
vdm.set_scrolling_state(False)
|
||||
assert vdm.is_currently_scrolling() is False
|
||||
|
||||
def test_format_date_with_ordinal(self):
|
||||
from datetime import datetime
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
dt = datetime(2025, 8, 1)
|
||||
result = vdm.format_date_with_ordinal(dt)
|
||||
assert '1st' in result
|
||||
dt = datetime(2025, 8, 3)
|
||||
result = vdm.format_date_with_ordinal(dt)
|
||||
assert '3rd' in result
|
||||
dt = datetime(2025, 8, 11)
|
||||
result = vdm.format_date_with_ordinal(dt)
|
||||
assert '11th' in result
|
||||
|
||||
|
||||
class TestWeatherDrawing:
|
||||
"""Test weather icon rendering."""
|
||||
|
||||
def test_draw_sun(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_sun(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_cloud(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_cloud(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_rain(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_rain(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_snow(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_snow(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_weather_icon_dispatches(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
for condition in ['clear', 'cloudy', 'rain', 'snow', 'storm', 'unknown']:
|
||||
vdm.clear()
|
||||
vdm.draw_weather_icon(condition, 0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0, f"draw_weather_icon('{condition}') should render pixels"
|
||||
@@ -3697,6 +3697,9 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return value
|
||||
|
||||
|
||||
MAX_LIST_EXPANSION = 1000
|
||||
|
||||
|
||||
def _set_nested_value(config, key_path, value):
|
||||
"""
|
||||
Set a value in a nested dict using dot notation path.
|
||||
@@ -3723,6 +3726,10 @@ def _set_nested_value(config, key_path, value):
|
||||
# Navigate/create intermediate dicts, greedily matching dotted keys.
|
||||
# We stop before the final part so we can set it as the leaf value.
|
||||
while i < len(parts) - 1:
|
||||
if not isinstance(current, dict):
|
||||
raise TypeError(
|
||||
f"Unexpected type {type(current).__name__!r} at path segment {parts[i]!r} in key_path {key_path!r}"
|
||||
)
|
||||
# Try progressively longer candidate keys (longest first) to match
|
||||
# dict keys that contain dots themselves (e.g. "eng.1").
|
||||
# Never consume the very last part (that's the leaf value key).
|
||||
@@ -3745,6 +3752,10 @@ def _set_nested_value(config, key_path, value):
|
||||
i += 1
|
||||
|
||||
# The remaining parts form the final key (may itself be dotted, e.g. "eng.1")
|
||||
if not isinstance(current, dict):
|
||||
raise TypeError(
|
||||
f"Cannot set key at end of key_path {key_path!r}: expected dict, got {type(current).__name__!r}"
|
||||
)
|
||||
final_key = '.'.join(parts[i:])
|
||||
if value is not None or final_key not in current:
|
||||
current[final_key] = value
|
||||
|
||||
@@ -81,25 +81,38 @@ const PluginInstallManager = {
|
||||
/**
|
||||
* Update all plugins.
|
||||
*
|
||||
* @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates
|
||||
* @returns {Promise<Array>} Update results
|
||||
*/
|
||||
async updateAll() {
|
||||
if (!window.PluginStateManager || !window.PluginStateManager.installedPlugins) {
|
||||
throw new Error('Installed plugins not loaded');
|
||||
}
|
||||
async updateAll(onProgress) {
|
||||
// Prefer PluginStateManager if populated, fall back to window.installedPlugins
|
||||
// (plugins_manager.js populates window.installedPlugins independently)
|
||||
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
|
||||
const plugins = (stateManagerPlugins && stateManagerPlugins.length > 0)
|
||||
? stateManagerPlugins
|
||||
: (window.installedPlugins || []);
|
||||
|
||||
const plugins = window.PluginStateManager.installedPlugins;
|
||||
if (!plugins.length) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i];
|
||||
if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
|
||||
try {
|
||||
const result = await this.update(plugin.id);
|
||||
const result = await window.PluginAPI.updatePlugin(plugin.id);
|
||||
results.push({ pluginId: plugin.id, success: true, result });
|
||||
} catch (error) {
|
||||
results.push({ pluginId: plugin.id, success: false, error });
|
||||
}
|
||||
}
|
||||
|
||||
// Reload plugin list once at the end
|
||||
if (window.PluginStateManager) {
|
||||
await window.PluginStateManager.loadInstalledPlugins();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
};
|
||||
@@ -109,5 +122,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PluginInstallManager;
|
||||
} else {
|
||||
window.PluginInstallManager = PluginInstallManager;
|
||||
window.updateAllPlugins = (onProgress) => PluginInstallManager.updateAll(onProgress);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,12 @@
|
||||
window.handleFileDrop = function(event, fieldId) {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
if (files.length === 0) return;
|
||||
// Route to single-file handler if this is a string file-upload widget
|
||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||
if (fileInput && fileInput.dataset.uploadEndpoint && fileInput.dataset.uploadEndpoint.trim() !== '') {
|
||||
window.handleSingleFileUpload(fieldId, files[0]);
|
||||
} else {
|
||||
window.handleFiles(fieldId, Array.from(files));
|
||||
}
|
||||
};
|
||||
@@ -88,6 +93,118 @@
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle single-file select for string file-upload widgets (e.g. credentials.json)
|
||||
* @param {Event} event - Change event
|
||||
* @param {string} fieldId - Field ID
|
||||
*/
|
||||
window.handleSingleFileSelect = function(event, fieldId) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
window.handleSingleFileUpload(fieldId, files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a single file for string file-upload widgets
|
||||
* Reads upload config from data attributes on the file input element.
|
||||
* @param {string} fieldId - Field ID
|
||||
* @param {File} file - File to upload
|
||||
*/
|
||||
window.handleSingleFileUpload = async function(fieldId, file) {
|
||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||
if (!fileInput) return;
|
||||
|
||||
const uploadEndpoint = fileInput.dataset.uploadEndpoint;
|
||||
const targetFilename = fileInput.dataset.targetFilename || 'file.json';
|
||||
const maxSizeMB = parseFloat(fileInput.dataset.maxSizeMb || '1');
|
||||
const allowedExtensions = (fileInput.dataset.allowedExtensions || '.json')
|
||||
.split(',').map(e => e.trim().toLowerCase());
|
||||
|
||||
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
|
||||
const notifyFn = window.showNotification || console.log;
|
||||
|
||||
// Guard: endpoint must be configured
|
||||
if (!uploadEndpoint) {
|
||||
notifyFn('No upload endpoint configured for this field', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate extension
|
||||
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
||||
if (!allowedExtensions.includes(fileExt)) {
|
||||
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-gray-500';
|
||||
statusDiv.textContent = '';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fas fa-spinner fa-spin mr-1';
|
||||
statusDiv.appendChild(spinner);
|
||||
statusDiv.appendChild(document.createTextNode('Uploading...'));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadEndpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Server error ${response.status}: ${body}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-green-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-check-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode(`Uploaded: ${targetFilename}`));
|
||||
}
|
||||
// Update hidden input with the target filename
|
||||
const hiddenInput = document.getElementById(fieldId);
|
||||
if (hiddenInput) hiddenInput.value = targetFilename;
|
||||
notifyFn(`${targetFilename} uploaded successfully`, 'success');
|
||||
} else {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-red-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
|
||||
}
|
||||
notifyFn(`Upload failed: ${data.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-red-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle multiple files upload
|
||||
* @param {string} fieldId - Field ID
|
||||
|
||||
@@ -1066,26 +1066,17 @@ window.initPluginsPage = function() {
|
||||
const onDemandForm = document.getElementById('on-demand-form');
|
||||
const onDemandModal = document.getElementById('on-demand-modal');
|
||||
|
||||
console.log('[initPluginsPage] Setting up button listeners:', {
|
||||
refreshBtn: !!refreshBtn,
|
||||
updateAllBtn: !!updateAllBtn,
|
||||
restartBtn: !!restartBtn
|
||||
});
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.replaceWith(refreshBtn.cloneNode(true));
|
||||
document.getElementById('refresh-plugins-btn').addEventListener('click', refreshPlugins);
|
||||
console.log('[initPluginsPage] Attached refreshPlugins listener');
|
||||
}
|
||||
if (updateAllBtn) {
|
||||
updateAllBtn.replaceWith(updateAllBtn.cloneNode(true));
|
||||
document.getElementById('update-all-plugins-btn').addEventListener('click', runUpdateAllPlugins);
|
||||
console.log('[initPluginsPage] Attached runUpdateAllPlugins listener');
|
||||
}
|
||||
if (restartBtn) {
|
||||
restartBtn.replaceWith(restartBtn.cloneNode(true));
|
||||
document.getElementById('restart-display-btn').addEventListener('click', restartDisplay);
|
||||
console.log('[initPluginsPage] Attached restartDisplay listener');
|
||||
}
|
||||
// Restore persisted store sort/perPage
|
||||
const storeSortEl = document.getElementById('store-sort');
|
||||
@@ -1135,28 +1126,22 @@ window.initPluginsPage = function() {
|
||||
|
||||
// Consolidated initialization function
|
||||
function initializePluginPageWhenReady() {
|
||||
console.log('Checking for plugin elements...');
|
||||
return window.initPluginsPage();
|
||||
}
|
||||
|
||||
// Single initialization entry point
|
||||
(function() {
|
||||
console.log('Plugin manager script loaded, setting up initialization...');
|
||||
|
||||
let initTimer = null;
|
||||
|
||||
|
||||
function attemptInit() {
|
||||
// Clear any pending timer
|
||||
if (initTimer) {
|
||||
clearTimeout(initTimer);
|
||||
initTimer = null;
|
||||
}
|
||||
|
||||
|
||||
// Try immediate initialization
|
||||
if (initializePluginPageWhenReady()) {
|
||||
console.log('Initialized immediately');
|
||||
return;
|
||||
}
|
||||
initializePluginPageWhenReady();
|
||||
}
|
||||
|
||||
// Strategy 1: Immediate check (for direct page loads)
|
||||
@@ -1763,8 +1748,7 @@ function startOnDemandStatusPolling() {
|
||||
|
||||
window.loadOnDemandStatus = loadOnDemandStatus;
|
||||
|
||||
async function runUpdateAllPlugins() {
|
||||
console.log('[runUpdateAllPlugins] Button clicked, checking for updates...');
|
||||
function runUpdateAllPlugins() {
|
||||
const button = document.getElementById('update-all-plugins-btn');
|
||||
|
||||
if (!button) {
|
||||
@@ -1786,58 +1770,47 @@ async function runUpdateAllPlugins() {
|
||||
button.dataset.running = 'true';
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-60', 'cursor-wait');
|
||||
button.innerHTML = '<i class="fas fa-sync fa-spin mr-2"></i>Checking...';
|
||||
|
||||
let updated = 0, upToDate = 0, failed = 0;
|
||||
const onProgress = (current, total, pluginId) => {
|
||||
button.innerHTML = `<i class="fas fa-sync fa-spin mr-2"></i>Updating ${current}/${total}...`;
|
||||
};
|
||||
|
||||
try {
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i];
|
||||
const pluginId = plugin.id;
|
||||
button.innerHTML = `<i class="fas fa-sync fa-spin mr-2"></i>Updating ${i + 1}/${plugins.length}...`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v3/plugins/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: pluginId })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (data.message && data.message.includes('already up to date')) {
|
||||
upToDate++;
|
||||
} else {
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(`Error updating ${pluginId}:`, error);
|
||||
Promise.resolve(window.updateAllPlugins(onProgress))
|
||||
.then(results => {
|
||||
if (!results || !results.length) {
|
||||
showNotification('No plugins to update.', 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh plugin list once at the end
|
||||
if (updated > 0) {
|
||||
loadInstalledPlugins(true);
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if (upToDate > 0) parts.push(`${upToDate} already up to date`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
const type = failed > 0 ? (updated > 0 ? 'warning' : 'error') : 'success';
|
||||
showNotification(parts.join(', '), type);
|
||||
} catch (error) {
|
||||
console.error('Bulk plugin update failed:', error);
|
||||
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
||||
} finally {
|
||||
button.innerHTML = originalContent;
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-60', 'cursor-wait');
|
||||
button.dataset.running = 'false';
|
||||
}
|
||||
let updated = 0, upToDate = 0, failed = 0;
|
||||
for (const r of results) {
|
||||
if (!r.success) {
|
||||
failed++;
|
||||
} else if (r.result && r.result.message && r.result.message.includes('already up to date')) {
|
||||
upToDate++;
|
||||
} else {
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
const parts = [];
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if (upToDate > 0) parts.push(`${upToDate} already up to date`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
const type = failed > 0 ? (updated > 0 ? 'warning' : 'error') : 'success';
|
||||
showNotification(parts.join(', '), type);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating all plugins:', error);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error updating all plugins: ' + error.message, 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
button.innerHTML = originalContent;
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-60', 'cursor-wait');
|
||||
button.dataset.running = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on-demand modal setup (runs unconditionally since modal is in base.html)
|
||||
@@ -5251,8 +5224,17 @@ function showStoreLoading(show) {
|
||||
|
||||
// ── Plugin Store: Client-Side Filter/Sort/Pagination ────────────────────────
|
||||
|
||||
function isStorePluginInstalled(pluginId) {
|
||||
return (window.installedPlugins || installedPlugins || []).some(p => p.id === pluginId);
|
||||
function isStorePluginInstalled(pluginIdOrPlugin) {
|
||||
const installed = window.installedPlugins || installedPlugins || [];
|
||||
// Accept either a plain ID string or a store plugin object (which may have plugin_path)
|
||||
if (typeof pluginIdOrPlugin === 'string') {
|
||||
return installed.some(p => p.id === pluginIdOrPlugin);
|
||||
}
|
||||
const storeId = pluginIdOrPlugin.id;
|
||||
// Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather")
|
||||
const pluginPath = pluginIdOrPlugin.plugin_path || '';
|
||||
const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null;
|
||||
return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId));
|
||||
}
|
||||
|
||||
function applyStoreFiltersAndSort(skipPageReset) {
|
||||
@@ -5282,9 +5264,9 @@ function applyStoreFiltersAndSort(skipPageReset) {
|
||||
|
||||
// Installed filter
|
||||
if (st.filterInstalled === true) {
|
||||
list = list.filter(plugin => isStorePluginInstalled(plugin.id));
|
||||
list = list.filter(plugin => isStorePluginInstalled(plugin));
|
||||
} else if (st.filterInstalled === false) {
|
||||
list = list.filter(plugin => !isStorePluginInstalled(plugin.id));
|
||||
list = list.filter(plugin => !isStorePluginInstalled(plugin));
|
||||
}
|
||||
|
||||
// Sort
|
||||
@@ -5531,7 +5513,7 @@ function renderPluginStore(plugins) {
|
||||
};
|
||||
|
||||
container.innerHTML = plugins.map(plugin => {
|
||||
const installed = isStorePluginInstalled(plugin.id);
|
||||
const installed = isStorePluginInstalled(plugin);
|
||||
return `
|
||||
<div class="plugin-card">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
@@ -6093,7 +6075,7 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
|
||||
};
|
||||
|
||||
container.innerHTML = plugins.map(plugin => {
|
||||
const isInstalled = installedPlugins.some(p => p.id === plugin.id);
|
||||
const isInstalled = isStorePluginInstalled(plugin);
|
||||
const pluginIdJs = escapeJs(plugin.id);
|
||||
const escapedUrlJs = escapeJs(registryUrl);
|
||||
const pluginPathJs = escapeJs(plugin.plugin_path || '');
|
||||
|
||||
@@ -537,7 +537,45 @@
|
||||
{% else %}
|
||||
{% set str_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
||||
{% set str_value = value if value is not none else (prop.default if prop.default is defined else '') %}
|
||||
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
|
||||
{% if str_widget == 'file-upload' %}
|
||||
{# Single-file upload widget for string fields (e.g., credentials.json) #}
|
||||
{% set upload_config = prop.get('x-upload-config') or {} %}
|
||||
{% set upload_endpoint = upload_config.get('upload_endpoint', '') %}
|
||||
{% set target_filename = upload_config.get('target_filename', 'file.json') %}
|
||||
{% set max_size_mb = upload_config.get('max_size_mb', 1) %}
|
||||
{% set allowed_extensions = upload_config.get('allowed_extensions', ['.json']) %}
|
||||
<div id="{{ field_id }}_upload_widget" class="mt-1">
|
||||
<div id="{{ field_id }}_drop_zone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Upload {{ target_filename }}"
|
||||
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
||||
ondragover="event.preventDefault()"
|
||||
data-field-id="{{ field_id }}"
|
||||
onclick="document.getElementById('{{ field_id }}_file_input').click()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
|
||||
<input type="file"
|
||||
id="{{ field_id }}_file_input"
|
||||
accept="{{ allowed_extensions|join(',') }}"
|
||||
style="display: none;"
|
||||
data-field-id="{{ field_id }}"
|
||||
data-upload-endpoint="{{ upload_endpoint }}"
|
||||
data-target-filename="{{ target_filename }}"
|
||||
data-max-size-mb="{{ max_size_mb }}"
|
||||
data-allowed-extensions="{{ allowed_extensions|join(',') }}"
|
||||
onchange="window.handleSingleFileSelect(event, this.dataset.fieldId)">
|
||||
<i class="fas fa-cloud-upload-alt text-2xl text-gray-400 mb-1"></i>
|
||||
<p class="text-sm text-gray-600">Click to upload {{ target_filename }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Max {{ max_size_mb }}MB ({{ allowed_extensions|join(', ') }})</p>
|
||||
</div>
|
||||
<div id="{{ field_id }}_upload_status" class="mt-2 text-xs text-gray-500" aria-live="polite"></div>
|
||||
<input type="hidden"
|
||||
id="{{ field_id }}"
|
||||
name="{{ full_key }}"
|
||||
value="{{ str_value }}">
|
||||
</div>
|
||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
|
||||
{# Render widget container #}
|
||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||
<script>
|
||||
|
||||