5 Commits

Author SHA1 Message Date
Chuck
eb143c44fa fix(web): render file-upload drop zone for string-type config fields (#271)
* feat: add March Madness plugin and tournament round logos

New dedicated March Madness plugin with scrolling tournament ticker:
- Fetches NCAA tournament data from ESPN scoreboard API
- Shows seeded matchups with team logos, live scores, and round separators
- Highlights upsets (higher seed beating lower seed) in gold
- Auto-enables during tournament window (March 10 - April 10)
- Configurable for NCAAM and NCAAW tournaments
- Vegas mode support via get_vegas_content()

Tournament round logo assets:
- MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png
- SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): prevent bulk-update from stalling on bundled/in-repo plugins

Three related bugs caused the bulk plugin update to stall at 3/19:

1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather
   than the plugin registry) had no metadata file, so update_plugin()
   returned False → API returned 500 → frontend queue halted.
   Fix: check for .plugin_metadata.json with install_type=bundled and
   return True immediately (these plugins update with LEDMatrix itself).

2. git config --get remote.origin.url (without --local) walked up the
   directory tree and found the parent LEDMatrix repo's remote URL for
   plugins that live inside plugin-repos/. This caused the store manager
   to attempt a 60-second git clone of the wrong repo for every update.
   Fix: use --local to scope the lookup to the plugin directory only.

3. hello-world manifest.json had a trailing comma causing JSON parse
   errors on every plugin discovery cycle (fixed on devpi directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #263 code review findings

- Replace self.is_enabled with BasePlugin.self.enabled in update(),
  display(), and supports_dynamic_duration() so runtime toggles work
- Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2),
  detected via league key or status_detail content
- Use live refresh interval (60s) for cache max_age during live games
  instead of hardcoded 300s
- Narrow broad except in _load_round_logos to (OSError, ValueError)
  with a fallback except Exception using logger.exception for traces
- Remove unused `situation` local variable from _parse_event()
- Add numpy>=1.24.0 to requirements.txt (imported but was missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): render file-upload drop zone for string-type config fields

String fields with x-widget: "file-upload" were falling through to a
plain text input because the template only handled the array case.
Adds a dedicated drop zone branch for string fields and corresponding
handleSingleFileSelect/handleSingleFileUpload JS handlers that POST to
the x-upload-config endpoint. Fixes credentials.json upload for the
calendar plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #271 code review findings

Inline fixes:
- manager.py: swap min_duration/max_duration if misconfigured, log warning
- manager.py: call session.close() and null session in cleanup() to prevent
  socket leaks on constrained hardware
- manager.py: remove blocking network I/O from display(); update() is the
  sole fetch path (already uses 60s live-game interval)
- manager.py: guard scroll_helper None before create_scrolling_image() in
  _create_ticker_image() to prevent crash when ScrollHelper is unavailable
- store_manager.py: replace bare "except Exception: pass" with debug log
  including plugin_id and path when reading .plugin_metadata.json
- file-upload.js: add endpoint guard (error if uploadEndpoint is falsy),
  client-side extension validation from data-allowed-extensions, and
  response.ok check before response.json() in handleSingleFileUpload
- plugin_config.html: add data-allowed-extensions attribute to single-file
  input so JS handler can read the allowed extensions list

Nitpick fixes:
- manager.py: use logger.exception() (includes traceback) instead of
  logger.error() for league fetch errors
- manager.py: remove redundant "{e}" from logger.exception() calls for
  round logo and March Madness logo load errors

Not fixed (by design):
- manifest.json repo naming: monorepo pattern is correct per project docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address second round of PR #271 code review findings

Inline fixes:
- requirements.txt: bump Pillow to >=9.1.0 (required for Image.Resampling.LANCZOS)
- file-upload.js: replace all statusDiv.innerHTML assignments with safe DOM
  creation (textContent + createElement) to prevent XSS from untrusted strings
- plugin_config.html: add role="button", tabindex="0", aria-label, onkeydown
  (Enter/Space) to drop zone for keyboard accessibility; add aria-live="polite"
  to status div for screen-reader announcements
- file-upload.js: tighten handleFileDrop endpoint check to non-empty string
  (dataset.uploadEndpoint.trim() !== '') so an empty attribute falls back to
  the multi-file handler

Nitpick fixes:
- manager.py: remove redundant cached_image/cached_array reassignments after
  create_scrolling_image() which already sets them internally
- manager.py: narrow bare except in _get_team_logo to (FileNotFoundError,
  OSError, ValueError) for expected I/O errors; log unexpected exceptions
- store_manager.py: narrow except to (OSError, ValueError) when reading
  .plugin_metadata.json so unrelated exceptions propagate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:12:31 -05:00
Chuck
275fed402e fix(logos): support logo downloads for custom soccer leagues (#262)
* fix(logos): support logo downloads for custom soccer leagues

LogoDownloader.fetch_teams_data() and fetch_single_team() only had
hardcoded API endpoints for predefined soccer leagues. Custom leagues
(e.g., por.1, mex.1) would silently fail when the ESPN game data
didn't include a direct logo URL. Now dynamically constructs the ESPN
teams API URL for any soccer_* league not in the predefined map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(logos): address PR review — directory, bulk download, and dedup

- get_logo_directory: custom soccer leagues now resolve to shared
  assets/sports/soccer_logos/ instead of creating per-league dirs
- download_all_missing_logos: use _resolve_api_url so custom soccer
  leagues are no longer silently skipped
- Extract _resolve_api_url helper to deduplicate dynamic URL
  construction between fetch_teams_data and fetch_single_team

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): preserve array item properties in _set_nested_value

When saving config with array-of-objects fields (e.g., custom_leagues),
_set_nested_value would replace existing list objects with dicts when
navigating dot-notation paths like "custom_leagues.0.name". This
destroyed any properties on array items that weren't submitted in the
form (e.g., display_modes, game_limits, filtering).

Now properly indexes into existing lists when encountering numeric path
segments, preserving all non-submitted properties on array items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address PR #262 code review security findings

- logo_downloader: validate league name against allowlist before
  constructing filesystem paths in get_logo_directory to prevent
  path traversal (reject anything not matching ^[a-z0-9_-]+$)
- logo_downloader: validate league_code against allowlist before
  interpolating into ESPN API URL in _resolve_api_url to prevent
  URL path injection; return None on invalid input
- api_v3: add MAX_LIST_EXPANSION=1000 cap to _set_nested_value list
  expansion; raise ValueError for out-of-bounds indices; replace
  silent break fallback with TypeError for unexpected traversal types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:18:29 -05:00
Chuck
38a9c1ed1b feat(march-madness): add NCAA tournament plugin and round logos (#263)
* feat: add March Madness plugin and tournament round logos

New dedicated March Madness plugin with scrolling tournament ticker:
- Fetches NCAA tournament data from ESPN scoreboard API
- Shows seeded matchups with team logos, live scores, and round separators
- Highlights upsets (higher seed beating lower seed) in gold
- Auto-enables during tournament window (March 10 - April 10)
- Configurable for NCAAM and NCAAW tournaments
- Vegas mode support via get_vegas_content()

Tournament round logo assets:
- MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png
- SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): prevent bulk-update from stalling on bundled/in-repo plugins

Three related bugs caused the bulk plugin update to stall at 3/19:

1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather
   than the plugin registry) had no metadata file, so update_plugin()
   returned False → API returned 500 → frontend queue halted.
   Fix: check for .plugin_metadata.json with install_type=bundled and
   return True immediately (these plugins update with LEDMatrix itself).

2. git config --get remote.origin.url (without --local) walked up the
   directory tree and found the parent LEDMatrix repo's remote URL for
   plugins that live inside plugin-repos/. This caused the store manager
   to attempt a 60-second git clone of the wrong repo for every update.
   Fix: use --local to scope the lookup to the plugin directory only.

3. hello-world manifest.json had a trailing comma causing JSON parse
   errors on every plugin discovery cycle (fixed on devpi directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #263 code review findings

- Replace self.is_enabled with BasePlugin.self.enabled in update(),
  display(), and supports_dynamic_duration() so runtime toggles work
- Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2),
  detected via league key or status_detail content
- Use live refresh interval (60s) for cache max_age during live games
  instead of hardcoded 300s
- Narrow broad except in _load_round_logos to (OSError, ValueError)
  with a fallback except Exception using logger.exception for traces
- Remove unused `situation` local variable from _parse_event()
- Add numpy>=1.24.0 to requirements.txt (imported but was missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:32:22 -05:00
Chuck
23f0176c18 feat: add dev preview server and CLI render script (#264)
* fix(web): wire up "Check & Update All" plugins button

window.updateAllPlugins was never assigned, so the button always showed
"Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(),
add per-plugin progress feedback in the button text, show a summary
notification on completion, and skip redundant plugin list reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add dev preview server, CLI render script, and visual test display manager

Adds local development tools for rapid plugin iteration without deploying to RPi:

- VisualTestDisplayManager: renders real pixels via PIL (same fonts/interface as production)
- Dev preview server (Flask): interactive web UI with plugin picker, auto-generated config
  forms, zoom/grid controls, and mock data support for API-dependent plugins
- CLI render script: render any plugin to PNG for AI-assisted visual feedback loops
- Updated test runner and conftest to auto-detect plugin-repos/ directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dev-preview): address code review issues

- Use get_logger() from src.logging_config instead of logging.getLogger()
  in visual_display_manager.py to match project logging conventions
- Eliminate duplicate public/private weather draw methods — public draw_sun/
  draw_cloud/draw_rain/draw_snow now delegate to the private _draw_* variants
  so plugins get consistent pixel output in tests vs production
- Default install_deps=False in dev_server.py and render_plugin.py — dev
  scripts don't need to run pip install; developers are expected to have
  plugin deps installed in their venv already
- Guard plugins_dir fixture against PermissionError during directory iteration
- Fix PluginInstallManager.updateAll() to fall back to window.installedPlugins
  when PluginStateManager.installedPlugins is empty (plugins_manager.js
  populates window.installedPlugins independently of PluginStateManager)
- Remove 5 debug console.log statements from plugins_manager.js button setup
  and initialization code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(scroll): fix scroll completion to prevent multi-pass wrapping

Change required_total_distance from total_scroll_width + display_width to
total_scroll_width alone. The scrolling image already contains display_width
pixels of blank initial padding, so reaching total_scroll_width means all
content has scrolled off-screen. The extra display_width term was causing
1-2+ unnecessary wrap-arounds, making the same games appear multiple times
and producing a black flicker between passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(dev-preview): address PR #264 code review findings

- docs/DEV_PREVIEW.md: add bash language tag to fenced code block
- scripts/dev_server.py: add MAX/MIN_WIDTH/HEIGHT constants and validate
  width/height in render endpoint; add structured logger calls to
  discover_plugins (missing dirs, hidden entries, missing manifest,
  JSON/OS errors, duplicate ids); add type annotations to all helpers
- scripts/render_plugin.py: add MIN/MAX_DIMENSION validation after
  parse_args; replace prints with get_logger() calls; narrow broad
  Exception catches to ImportError/OSError/ValueError in plugin load
  block; add type annotations to all helpers and main(); rename unused
  module binding to _module
- scripts/run_plugin_tests.py: wrap plugins_path.iterdir() in
  try/except PermissionError with fallback to plugin-repos/
- scripts/templates/dev_preview.html: replace non-focusable div toggles
  with button role="switch" + aria-checked; add keyboard handlers
  (Enter/Space); sync aria-checked in toggleGrid/toggleAutoRefresh
- src/common/scroll_helper.py: early-guard zero total_scroll_width to
  keep scroll_position at 0 and skip completion/wrap logic
- src/plugin_system/testing/visual_display_manager.py: forward color
  arg in draw_cloud -> _draw_cloud; add color param to _draw_cloud;
  restore _scrolling_state in reset(); narrow broad Exception catches in
  _load_fonts to FileNotFoundError/OSError/ImportError; add explicit
  type annotations to draw_text
- test/plugins/test_visual_rendering.py: use context manager for
  Image.open in test_save_snapshot
- test/plugins/conftest.py: add return type hints to all fixtures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add bandit and gitleaks pre-commit hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:57:42 -05:00
Chuck
9465fcda6e fix(store): fix installed status detection for plugins with path-derived IDs (#270)
The plugin registry uses short IDs (e.g. "weather", "stocks") but
plugin_path points to the actual installed directory name (e.g.
"plugins/ledmatrix-weather"). isStorePluginInstalled() was only
comparing registry IDs, causing all monorepo plugins with mismatched
IDs to show as not installed in the store UI.

- Updated isStorePluginInstalled() to also check the last segment of
  plugin_path against installed plugin IDs
- Updated all 3 call sites to pass the full plugin object instead of
  just plugin.id
- Fixed the same bug in renderCustomRegistryPlugins() which used the
  same direct ID comparison

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:35:08 -05:00
29 changed files with 3462 additions and 105 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

166
docs/DEV_PREVIEW.md Normal file
View 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
```

View 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"]
}

View 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()

View 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"
}

View 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
View 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
View 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())

View File

@@ -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

View 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>

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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',
]

View 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)

View File

@@ -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)

View 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"

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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 || '');

View File

@@ -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>