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>
This commit is contained in:
Chuck
2026-02-24 17:57:42 -05:00
committed by GitHub
parent 9465fcda6e
commit 23f0176c18
13 changed files with 2158 additions and 94 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

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

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,11 +255,18 @@ 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

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

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

@@ -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,14 +1126,11 @@ 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() {
@@ -1153,10 +1141,7 @@ function initializePluginPageWhenReady() {
}
// 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)