diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a758d67..10073329 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/docs/DEV_PREVIEW.md b/docs/DEV_PREVIEW.md new file mode 100644 index 00000000..9338f97e --- /dev/null +++ b/docs/DEV_PREVIEW.md @@ -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 +``` diff --git a/scripts/dev_server.py b/scripts/dev_server.py new file mode 100644 index 00000000..18374405 --- /dev/null +++ b/scripts/dev_server.py @@ -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//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//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() diff --git a/scripts/render_plugin.py b/scripts/render_plugin.py new file mode 100644 index 00000000..013828ae --- /dev/null +++ b/scripts/render_plugin.py @@ -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()) diff --git a/scripts/run_plugin_tests.py b/scripts/run_plugin_tests.py index 68de65fc..7355a8e6 100755 --- a/scripts/run_plugin_tests.py +++ b/scripts/run_plugin_tests.py @@ -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 diff --git a/scripts/templates/dev_preview.html b/scripts/templates/dev_preview.html new file mode 100644 index 00000000..84756a66 --- /dev/null +++ b/scripts/templates/dev_preview.html @@ -0,0 +1,595 @@ + + + + + + LEDMatrix Dev Preview + + + + + + +
+
+
+
+

LEDMatrix Dev Preview

+
+
+ Ready + +
+
+
+ + +
+ + +
+ + +
+ + +

+
+ + +
+ +
+ + x + + px +
+
+ + +
+
+ + +
+
+

+ Select a plugin to load its configuration. +

+
+ + +
+ + Mock Data (for API-dependent plugins) + +
+ +

+ JSON object with cache keys. Find keys by searching plugin's manager.py for cache_manager.set() calls. +

+
+
+ + +
+ +
+
+ + +
+ +
+
+ Display Preview +
+ +
+
+ + +
+
+
+ + +

+ Select a plugin and click Render to preview. +

+
+
+
+ + +
+ +
+ + + 8x +
+ + +
+ + +
+ + +
+ + +
+
+
+ + + +
+
+ + + + diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index 56d8065f..a35ae833 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -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 diff --git a/src/plugin_system/testing/__init__.py b/src/plugin_system/testing/__init__.py index f6acc75c..ebcf5d60 100644 --- a/src/plugin_system/testing/__init__.py +++ b/src/plugin_system/testing/__init__.py @@ -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', ] diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py new file mode 100644 index 00000000..17afcf45 --- /dev/null +++ b/src/plugin_system/testing/visual_display_manager.py @@ -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) diff --git a/test/plugins/conftest.py b/test/plugins/conftest.py index 5d854571..a6a19c8f 100644 --- a/test/plugins/conftest.py +++ b/test/plugins/conftest.py @@ -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) diff --git a/test/plugins/test_visual_rendering.py b/test/plugins/test_visual_rendering.py new file mode 100644 index 00000000..971e0635 --- /dev/null +++ b/test/plugins/test_visual_rendering.py @@ -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" diff --git a/web_interface/static/v3/js/plugins/install_manager.js b/web_interface/static/v3/js/plugins/install_manager.js index 0b6fbacc..1a13d05d 100644 --- a/web_interface/static/v3/js/plugins/install_manager.js +++ b/web_interface/static/v3/js/plugins/install_manager.js @@ -81,25 +81,38 @@ const PluginInstallManager = { /** * Update all plugins. * + * @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates * @returns {Promise} 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); } diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index b6111066..d122d5fe 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -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 = 'Checking...'; - let updated = 0, upToDate = 0, failed = 0; + const onProgress = (current, total, pluginId) => { + button.innerHTML = `Updating ${current}/${total}...`; + }; - try { - for (let i = 0; i < plugins.length; i++) { - const plugin = plugins[i]; - const pluginId = plugin.id; - button.innerHTML = `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)