diff --git a/CLAUDE.md b/CLAUDE.md index 61b775005..c0b6f62a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,31 +1,609 @@ -# LEDMatrix +# LEDMatrix — AI Assistant Reference -## Project Structure -- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class -- `web_interface/` — Flask web UI (blueprints, templates, static JS) -- `config/config.json` — User plugin configuration (persists across plugin reinstalls) -- `plugins/` — Installed plugins directory (gitignored) -- `plugin-repos/` — Development symlinks to monorepo plugin dirs +## Project Overview + +LEDMatrix is a Raspberry Pi-based LED matrix display controller with a plugin architecture, web UI, and optional Vegas-style continuous scroll mode. It runs two services: a display controller (`run.py`) and a web interface (`web_interface/app.py`). + +--- + +## Directory Structure + +```text +LEDMatrix/ +├── run.py # Main entry point (display controller) +├── display_controller.py # Legacy top-level shim (do not modify) +├── requirements.txt # Core Python dependencies +├── requirements-emulator.txt # Emulator-only dependencies +├── pytest.ini # Test configuration +├── mypy.ini # Type checking configuration +├── config/ +│ ├── config.json # Runtime config (gitignored, user-created) +│ ├── config.template.json # Template to copy for new installations +│ └── config_secrets.json # API keys (gitignored, user-created) +├── src/ +│ ├── display_controller.py # DisplayController class (core loop) +│ ├── display_manager.py # DisplayManager (singleton, wraps rgbmatrix) +│ ├── config_manager.py # ConfigManager (loads/saves config) +│ ├── config_manager_atomic.py # Atomic write + backup/rollback support +│ ├── config_service.py # ConfigService (hot-reload wrapper) +│ ├── cache_manager.py # CacheManager (memory + disk cache) +│ ├── font_manager.py # FontManager (TTF/BDF font loading) +│ ├── logging_config.py # Centralized logging (get_logger) +│ ├── exceptions.py # Custom exceptions (PluginError, CacheError, ...) +│ ├── startup_validator.py # Startup configuration validation +│ ├── wifi_manager.py # WiFi management +│ ├── layout_manager.py # Layout helpers +│ ├── image_utils.py # PIL image utilities +│ ├── vegas_mode/ # Vegas scroll mode subsystem +│ │ ├── coordinator.py # VegasModeCoordinator (main orchestrator) +│ │ ├── config.py # VegasModeConfig dataclass +│ │ ├── plugin_adapter.py # Adapts plugins for Vegas rendering +│ │ ├── render_pipeline.py # High-FPS render loop +│ │ └── stream_manager.py # Content stream management +│ ├── plugin_system/ +│ │ ├── base_plugin.py # BasePlugin ABC + VegasDisplayMode enum +│ │ ├── plugin_manager.py # PluginManager (discovery, loading, lifecycle) +│ │ ├── plugin_loader.py # Module-level loading + dep installation +│ │ ├── plugin_executor.py # Isolated execution with timeouts +│ │ ├── plugin_state.py # PluginState enum + PluginStateManager +│ │ ├── store_manager.py # PluginStoreManager (install/update/remove) +│ │ ├── schema_manager.py # JSON Schema validation for plugin configs +│ │ ├── operation_queue.py # PluginOperationQueue (serialized ops) +│ │ ├── operation_types.py # OperationType, OperationStatus enums +│ │ ├── operation_history.py # Persistent operation history +│ │ ├── state_manager.py # State manager for web UI +│ │ ├── state_reconciliation.py # Reconciles plugin state with config +│ │ ├── health_monitor.py # Plugin health monitoring +│ │ ├── resource_monitor.py # Resource usage tracking +│ │ ├── saved_repositories.py # SavedRepositoriesManager (custom repos) +│ │ └── testing/ +│ │ ├── mocks.py # MockDisplayManager, MockCacheManager, etc. +│ │ └── plugin_test_base.py # PluginTestCase base class +│ ├── base_classes/ # Reusable base classes for sport plugins +│ │ ├── sports.py # SportsCore ABC +│ │ ├── baseball.py / basketball.py / football.py / hockey.py +│ │ ├── api_extractors.py # APIDataExtractor base +│ │ └── data_sources.py # DataSource base +│ ├── common/ # Shared utilities for plugins +│ │ ├── display_helper.py # DisplayHelper (image layouts, compositing) +│ │ ├── scroll_helper.py # ScrollHelper (smooth scrolling) +│ │ ├── text_helper.py # TextHelper (text rendering, wrapping) +│ │ ├── logo_helper.py # LogoHelper (team logos) +│ │ ├── game_helper.py # GameHelper (sport game utilities) +│ │ ├── api_helper.py # APIHelper (HTTP with retry) +│ │ ├── config_helper.py # ConfigHelper (config access utilities) +│ │ ├── error_handler.py # ErrorHandler (common error patterns) +│ │ ├── utils.py # General utilities +│ │ └── permission_utils.py # File permission utilities +│ ├── cache/ # Cache subsystem components +│ │ ├── memory_cache.py # In-memory LRU cache +│ │ ├── disk_cache.py # Disk-backed cache +│ │ ├── cache_strategy.py # TTL strategy per sport/source +│ │ └── cache_metrics.py # Hit/miss metrics +│ └── web_interface/ # Web API helpers (not Flask app itself) +│ ├── api_helpers.py # success_response(), error_response() +│ ├── validators.py # Input validation + sanitization +│ ├── errors.py # ErrorCode enum +│ └── logging_config.py # Web-specific logging helpers +├── web_interface/ # Flask web application +│ ├── app.py # Flask app factory + manager initialization +│ ├── start.py # WSGI entry point +│ ├── blueprints/ +│ │ ├── api_v3.py # REST API (base URL: /api/v3) +│ │ └── pages_v3.py # Server-rendered HTML pages +│ ├── templates/v3/ # Jinja2 templates +│ │ ├── base.html / index.html +│ │ └── partials/ # HTMX partial templates +│ └── static/v3/ +│ ├── app.js / app.css +│ └── js/ +│ ├── widgets/ # Custom web components (Alpine.js based) +│ └── plugins/ # Plugin management JS modules +├── plugins/ # Installed plugins (gitignored) +├── plugin-repos/ # Dev symlinks to monorepo plugin dirs +│ └── web-ui-info/ # Built-in info plugin +├── assets/ +│ ├── fonts/ # BDF and TTF fonts +│ ├── broadcast_logos/ # Network logos (PNG) +│ ├── news_logos/ # News channel logos +│ └── sports/ # Team logos by sport (PNG) +├── schema/ +│ └── manifest_schema.json # JSON Schema for manifest.json validation +├── systemd/ # systemd service templates +│ ├── ledmatrix.service # Display controller service (runs as root) +│ └── ledmatrix-web.service # Web interface service (runs as root) +├── scripts/ +│ ├── dev/ +│ │ ├── run_emulator.sh # Launch with RGBMatrixEmulator +│ │ └── dev_plugin_setup.sh # Set up plugin-repos symlinks +│ ├── install/ # Installation scripts +│ └── fix_perms/ # Permission fix utilities +├── test/ # Test suite (pytest) +│ ├── conftest.py +│ ├── plugins/ # Per-plugin test files +│ └── web_interface/ # Web interface tests +└── docs/ # Extended documentation +``` + +--- + +## Running the Application + +### Development (emulator mode) +```bash +python run.py --emulator # Run with RGBMatrixEmulator (pygame) +python run.py --emulator --debug # With verbose debug logging +``` + +### Production (Raspberry Pi) +```bash +python run.py # Hardware mode (requires root for GPIO) +sudo python run.py # With root for GPIO access +``` + +### Web Interface +```bash +python web_interface/start.py # Start web UI (port 5000) +# or +bash web_interface/run.sh +``` + +### Systemd Services +```bash +sudo systemctl start ledmatrix # Display controller +sudo systemctl start ledmatrix-web # Web interface +sudo journalctl -u ledmatrix -f # Follow display logs +sudo journalctl -u ledmatrix-web -f # Follow web logs +``` + +**Important**: The display service runs as `root` (GPIO requires it). The web service also runs as root but should be treated as a local-only application. + +--- + +## Configuration + +### Main Config: `config/config.json` +Copy from `config/config.template.json`. Key sections: + +```json +{ + "timezone": "America/Chicago", + "location": { "city": "Dallas", "state": "Texas", "country": "US" }, + "display": { + "hardware": { + "rows": 32, "cols": 64, "chain_length": 2, "brightness": 90, + "hardware_mapping": "adafruit-hat-pwm" + }, + "runtime": { "gpio_slowdown": 3 }, + "vegas_scroll": { "enabled": false, "scroll_speed": 50 } + }, + "plugin_system": { + "plugins_directory": "plugins", + "auto_discover": true + }, + "schedule": { "enabled": true, "start_time": "07:00", "end_time": "23:00" } +} +``` + +### Secrets Config: `config/config_secrets.json` +Copy from `config/config_secrets.template.json`. Contains API keys: +- `ledmatrix-weather.api_key` — OpenWeatherMap +- `music.SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` +- `github.api_token` — For private plugin repos / higher rate limits +- `youtube.api_key` / `channel_id` + +Plugin configs are stored inside `config/config.json` under their `plugin_id` key, NOT in the plugin directories. This persists across reinstalls. + +### Hot Reload +Config can be reloaded without restart. Set `LEDMATRIX_HOT_RELOAD=false` to disable. + +--- ## Plugin System -- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py` -- Required abstract methods: `update()`, `display(force_clear=False)` -- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt` -- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager` -- Config schemas use JSON Schema Draft-7 -- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height` + +### Plugin Lifecycle +```text +UNLOADED → LOADED → ENABLED → RUNNING → (back to ENABLED) + ↓ + ERROR + ↓ + DISABLED +``` + +### BasePlugin Contract +All plugins must inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`: + +```python +from src.plugin_system.base_plugin import BasePlugin + +class MyPlugin(BasePlugin): + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + # self.logger is automatically configured via get_logger() + # self.config, self.enabled, self.plugin_id are set by super() + + def update(self) -> None: + """Called on update_interval. Fetch data, populate cache.""" + ... + + def display(self, force_clear: bool = False) -> None: + """Called during rotation. Render to display_manager.""" + ... +``` + +**Required abstract methods**: `update()` and `display(force_clear=False)` + +**Optional overrides** (see base_plugin.py for full list): +- `validate_config()` — Extra config validation +- `cleanup()` — Release resources on unload +- `on_config_change(new_config)` — Hot-reload support +- `has_live_content()` / `has_live_priority()` — Live priority takeover +- `get_vegas_content()` / `get_vegas_display_mode()` — Vegas mode integration +- `is_cycle_complete()` / `reset_cycle_state()` — Dynamic display duration +- `get_info()` — Web UI status display + +### Plugin File Structure +```text +plugins// +├── manifest.json # Plugin metadata (required) +├── config_schema.json # JSON Schema Draft-7 for config (required) +├── manager.py # Plugin class (required, entry_point in manifest) +└── requirements.txt # Plugin-specific pip dependencies +``` + +### manifest.json Fields +```json +{ + "id": "my-plugin", + "name": "My Plugin", + "version": "1.0.0", + "entry_point": "manager.py", + "class_name": "MyPlugin", + "category": "custom", + "update_interval": 60, + "default_duration": 15, + "display_modes": ["my-plugin"], + "min_ledmatrix_version": "2.0.0" +} +``` + +### config_schema.json +Use JSON Schema Draft-7. Standard properties every plugin should include: +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "display_duration": { "type": "number", "default": 15, "minimum": 1 }, + "live_priority": { "type": "boolean", "default": false } + }, + "required": ["enabled"], + "additionalProperties": false +} +``` + +### Display Dimensions +Always read dynamically — never hardcode matrix dimensions: +```python +width = self.display_manager.matrix.width # e.g., 128 (64 * chain_length) +height = self.display_manager.matrix.height # e.g., 32 +``` + +### Caching in Plugins +```python +def update(self): + cache_key = f"{self.plugin_id}_data" + cached = self.cache_manager.get(cache_key, max_age=3600) + if cached: + self.data = cached + return + self.data = self._fetch_from_api() + self.cache_manager.set(cache_key, self.data, ttl=3600) + # For stale fallback on API failure: + # self.cache_manager.get(cache_key, max_age=31536000) +``` + +--- ## Plugin Store Architecture + - Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos) -- Plugin repo naming convention: `ledmatrix-` (e.g., `ledmatrix-football-scoreboard`) -- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json` -- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall -- Monorepo plugins are installed via ZIP extraction (no `.git` directory) -- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version) -- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls -- Third-party plugins can use their own repo URL with empty `plugin_path` +- Registry URL: `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json` +- `PluginStoreManager` (`src/plugin_system/store_manager.py`) handles all install/update/uninstall +- Monorepo plugins install via ZIP extraction — no `.git` directory present +- Update detection uses version comparison: manifest `version` vs registry `latest_version` +- Third-party plugins use their own GitHub repo URL with empty `plugin_path` +- Plugin configs in `config/config.json` under the plugin ID key — safe across reinstalls + +**Monorepo development workflow**: When modifying a plugin in the monorepo, you MUST: +1. Bump `version` in `manifest.json` +2. Run `python update_registry.py` in the monorepo root +Skipping either step means users won't receive the update. + +--- + +## Vegas Scroll Mode + +A continuous horizontal scroll that combines all plugin content. Configured under `display.vegas_scroll` in `config.json`. + +### Plugin Vegas Integration +Three display modes (set via `get_vegas_display_mode()` or config `vegas_mode`): +- `VegasDisplayMode.SCROLL` — Content scrolls continuously (sports scores, news tickers) +- `VegasDisplayMode.FIXED_SEGMENT` — Fixed-width block scrolls past (clock, weather) +- `VegasDisplayMode.STATIC` — Scroll pauses, plugin displays for its duration, resumes + +```python +from src.plugin_system.base_plugin import VegasDisplayMode + +def get_vegas_display_mode(self): + return VegasDisplayMode.SCROLL + +def get_vegas_content(self): + # Return PIL Image or list of PIL Images, or None to capture display() + return [self._render_game(game) for game in self.games] + +def get_vegas_segment_width(self): + # For FIXED_SEGMENT: number of panels to occupy + return self.config.get("vegas_panel_count", 2) +``` + +--- + +## Web Interface + +- Flask app at `web_interface/app.py`; REST API at `web_interface/blueprints/api_v3.py` +- Base URL: `http://:5000/api/v3` +- Uses HTMX + Alpine.js for reactive UI without a full SPA framework +- All API responses follow the standard envelope: + ```json + { "status": "success" | "error", "data": {...}, "message": "..." } + ``` +- Use `src/web_interface/api_helpers.py`: `success_response()`, `error_response()` +- Plugin operations are serialized via `PluginOperationQueue` to prevent conflicts + +### Key API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v3/config/main` | Read main config | +| POST | `/api/v3/config/main` | Save main config | +| GET | `/api/v3/plugins` | List all plugins | +| POST | `/api/v3/plugins//install` | Install plugin | +| POST | `/api/v3/plugins//uninstall` | Uninstall plugin | +| GET | `/api/v3/plugins//config` | Get plugin config | +| POST | `/api/v3/plugins//config` | Save plugin config | +| GET | `/api/v3/store/registry` | Browse plugin store | +| POST | `/api/v3/display/restart` | Restart display service | +| GET | `/api/v3/system/logs` | Get system logs | + +--- + +## Logging + +Always use `get_logger()` from `src.logging_config` — never `logging.getLogger()` directly in plugins or core src code. + +```python +from src.logging_config import get_logger + +# In a plugin (plugin_id context automatically added): +self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id) +# This is done automatically by BasePlugin.__init__ + +# In core src modules: +logger = get_logger(__name__) +``` + +Log level guidelines: +- `logger.info()` — Normal operations, status updates +- `logger.debug()` — Detailed troubleshooting info +- `logger.warning()` — Non-critical issues +- `logger.error()` — Problems requiring attention + +Use consistent prefixes in messages: `[PluginName] message`, `[NHL Live] fetching data` + +--- + +## Testing + +### Running Tests +```bash +pytest # Full test suite with coverage +pytest test/plugins/ # Plugin tests only +pytest test/test_cache_manager.py # Single file +pytest -k "test_update" # Filter by name +pytest --no-cov # Skip coverage (faster) +``` + +### Writing Plugin Tests +Use `PluginTestCase` from `src.plugin_system.testing.plugin_test_base`: + +```python +from src.plugin_system.testing.plugin_test_base import PluginTestCase + +class TestMyPlugin(PluginTestCase): + def test_initialization(self): + plugin = self.create_plugin_instance(MyPlugin) + self.assertTrue(plugin.enabled) + + def test_update_uses_cache(self): + plugin = self.create_plugin_instance(MyPlugin) + self.cache_manager.set("my-plugin_data", {"key": "val"}) + plugin.update() + # verify plugin.data was loaded from cache +``` + +Available mocks: `MockDisplayManager(width, height)`, `MockCacheManager`, `MockConfigManager`, `MockPluginManager` + +### Test Markers +```python +@pytest.mark.unit # Fast, isolated +@pytest.mark.integration # Slower, may need external services +@pytest.mark.hardware # Requires actual Raspberry Pi hardware +@pytest.mark.plugin # Plugin-related +``` + +--- + +## Coding Standards + +### Naming +- Classes: `PascalCase` (e.g., `MyScoreboardPlugin`) +- Functions/variables: `snake_case` +- Constants: `UPPER_SNAKE_CASE` +- Private methods: `_leading_underscore` + +### Python Patterns +- Type hints on all public function signatures +- Specific exception types — never bare `except:` +- Docstrings on all classes and non-trivial methods +- Provide sensible defaults in code, not config + +### Manager/Plugin Pattern +```python +class MyPlugin(BasePlugin): + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + super().__init__(...) # Always call super first + # Load config values with defaults + self.my_setting = config.get("my_setting", "default") + + def update(self): # Fetch/process data + ... + + def display(self, force_clear=False): # Render to matrix + ... +``` + +--- ## Common Pitfalls -- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat -- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()` -- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update + +- **paho-mqtt 2.x**: Needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat +- **BasePlugin logger**: Use `get_logger()` from `src.logging_config`, not `logging.getLogger()` +- **Monorepo plugin updates**: Must bump `manifest.json` version AND run `python update_registry.py` +- **Display dimensions**: Read from `self.display_manager.matrix.width/height` — never hardcode +- **`sys.dont_write_bytecode = True`** is set in `run.py`: root-owned `__pycache__` files block web service (non-root) from updating plugins +- **Config path**: ConfigManager defaults to `config/config.json` relative to CWD — must run from project root +- **Plugin configs**: Stored in `config/config.json` under the plugin ID key, NOT inside plugin directories +- **Operation serialization**: Plugin install/uninstall/update goes through `PluginOperationQueue` — don't call store manager directly from web handlers +- **DisplayManager is a singleton**: Don't create multiple instances; use the existing one passed to plugins +- **Secret keys**: Store in `config/config_secrets.json` (gitignored) — never commit API keys + +--- + +## Development Workflow + +### Creating a New Plugin +1. Copy `.cursor/plugin_templates/` into `plugin-repos//` +2. Fill in `manifest.json` (set `id`, `name`, `version`, `class_name`, `display_modes`) +3. Fill in `config_schema.json` with your plugin's settings +4. Implement `manager.py` inheriting from `BasePlugin` +5. Add deps to `requirements.txt` +6. Symlink for dev: `python scripts/setup_plugin_repos.py` +7. Test: `pytest test/plugins/test_.py` + +### Emulator Development (non-Pi) +```bash +pip install -r requirements-emulator.txt +python run.py --emulator +``` + +### Pre-commit Hooks +```bash +pip install pre-commit +pre-commit install +pre-commit run --all-files +``` + +### Type Checking +```bash +mypy src/ --config-file mypy.ini +``` + +--- + +## CodeRabbit Review Checklist + +CodeRabbit performs comprehensive AI-powered code reviews covering 40+ industry-standard tools. Use this checklist during development to catch issues before review: + +### Security (Critical) + +- **Path Traversal**: Validate all user-provided file/directory paths; reject `..`, `/`, `\` in filenames; use `pathlib.Path.resolve().relative_to()` to ensure paths stay confined +- **Command Injection**: Never use user input in shell commands; use `subprocess` with list args, never strings; use `sys.executable` instead of hardcoded `python3` +- **XSS in Templates/HTML**: Escape all user-provided data in inline JavaScript; use helper functions like `escapeJsString()` for filenames in `onclick`/`onchange` handlers +- **Secret Exposure**: Never return secrets in API responses; mask `x-secret` config fields with empty strings in GET endpoints; filter empty-string values in POST to preserve existing secrets +- **Input Validation**: Validate enum/category values against allowlists (regex: `[a-z0-9_-]+`); validate boolean inputs (accept both `bool` type and `"true"`/`"false"` strings); enforce file extensions (`.json`) before path checks +- **Access Control**: Implement fail-closed error handling for sensitive operations (raise exceptions rather than silent `pass` on secret masking failures) + +### Error Handling & Robustness + +- **Bare Except Blocks**: Replace `except:` with specific exception types (`OSError`, `TypeError`, `ValueError`, `json.JSONDecodeError`, etc.); add `self.logger.warning()` or `.error()` for diagnostics +- **Broad Exception Catches**: Narrow `except Exception` to targeted types; unhandled edge cases should fail explicitly, not silently +- **Type Coercion**: Validate config values before use; create helper functions for type validation (e.g., `_get_positive_float()` that coerces and validates numeric config) +- **None/Empty Checks**: Use `.get()` for dict access with defaults; check for whitespace-only strings (`v.strip() == ''`), not just empty strings; validate before subscript access + +### Code Quality & Performance + +- **Resource Caching**: Load fonts, images, and other expensive resources once and cache as instance variables; don't call `ImageFont.truetype()` or disk I/O in hot loops (like `display()` on Pi) +- **Dead Code**: Remove unused imports, variables, and unreachable code paths; Ruff/pylint will catch these, but review for logical dead code CodeRabbit identifies +- **Redundant Assignments**: Don't assign values that are immediately overwritten; consolidate initialization steps +- **Disk I/O in Loops**: Move file operations outside loop bodies; cache file contents in memory or use disk cache with TTL strategies +- **Type Hints**: Add type annotations to all public function signatures (parameters and return types); use `Tuple`, `Optional`, `List`, etc. from `typing` + +### Configuration & Hot-Reload + +- **Config Validation**: Validate all config values on load and on `on_config_change()`; provide sensible defaults in code, not config +- **Hot-Reload Support**: Implement `on_config_change(new_config)` if config can be modified via web UI; re-initialize resources if config changes (e.g., fonts, API keys) +- **Default Values**: Return complete default configs (including all required fields) when no user config exists; don't return partial configs + +### API Design & Public Interface + +- **Private Methods**: Use `_leading_underscore` for internal methods; review class interface for unnecessary public exposure +- **Consistent Defaults**: Use the same default values in multiple places (schemas, code fallbacks, API responses); store defaults in constants to avoid duplication +- **Method Documentation**: Write docstrings for non-trivial methods, especially those affecting display/state; clarify intent of `force_clear`, config parameters, etc. + +### Thread Safety & State Management + +- **Blocking Operations**: Be cautious with `time.sleep()` in hot loops; use non-blocking patterns or event loops for long delays +- **State Isolation**: Store state in instance variables, not globals; ensure plugin instances don't share mutable state +- **Resource Cleanup**: Implement `cleanup()` method for long-lived resources (connections, threads, file handles); ensure cleanup runs on unload + +### Markdown Documentation + +- **Code Blocks**: Add language identifiers to fenced code blocks (e.g., ` ```python `, ` ```json `, ` ```text `) +- **Spacing**: Add blank lines before tables and complex structures (markdown linting MD040, MD058) + +### Common CodeRabbit Patterns + +CodeRabbit identifies architectural issues that go beyond linter reach: + +- **God Classes**: Classes with too many responsibilities; consider breaking into focused helper classes +- **Primitive Obsession**: Using raw strings/dicts for complex concepts; create dataclasses or enums +- **Shotgun Surgery**: Changes affecting many files with minimal per-file changes; suggests architectural refactoring +- **Circular Dependencies**: Import cycles that block modular reuse; review import hierarchy +- **Missing Test Coverage**: Identify untested code paths; write tests for error handling and edge cases +- **Performance Anti-Patterns**: O(n) lookups in loops, inefficient data structures, memory leaks + +### Review Workflow + +1. **Before creating a PR**: Run CodeRabbit review locally (if available) or expect it on GitHub +2. **During development**: Reference this checklist to catch common issues early +3. **On CodeRabbit feedback**: Categorize as **security** (critical), **bug** (major), **minor** (style/optimization), or **nitpick** (taste) +4. **Addressing findings**: Create a new commit (not amend) for each category of fixes; reference the CodeRabbit categories in commit messages + +--- + +## Key Source Files for Common Tasks + +| Task | File | +|------|------| +| Add a new plugin | `src/plugin_system/base_plugin.py` (extend) | +| Change display rotation | `src/display_controller.py` | +| Add web API endpoint | `web_interface/blueprints/api_v3.py` | +| Add web UI page/partial | `web_interface/blueprints/pages_v3.py` + `templates/v3/` | +| Add a UI widget | `web_interface/static/v3/js/widgets/` | +| Modify config schema | `config/config.template.json` | +| Add a custom exception | `src/exceptions.py` | +| Change cache behavior | `src/cache/cache_strategy.py` | +| Vegas mode rendering | `src/vegas_mode/render_pipeline.py` | +| Plugin store operations | `src/plugin_system/store_manager.py` | diff --git a/config/config_secrets.template.json b/config/config_secrets.template.json index 8117ec271..d42de9570 100644 --- a/config/config_secrets.template.json +++ b/config/config_secrets.template.json @@ -1,5 +1,5 @@ { - "weather": { + "ledmatrix-weather": { "api_key": "YOUR_OPENWEATHERMAP_API_KEY" }, "youtube": { diff --git a/plugin-repos/of-the-day/manager.py b/plugin-repos/of-the-day/manager.py new file mode 100644 index 000000000..bd1b68559 --- /dev/null +++ b/plugin-repos/of-the-day/manager.py @@ -0,0 +1,805 @@ +""" +Of The Day Plugin for LEDMatrix + +Display daily featured content like Word of the Day, Bible verses, or custom items. +Supports multiple categories with automatic rotation and configurable data sources. + +Features: +- Multiple category support (Word of the Day, Bible verses, etc.) +- Automatic daily updates +- Rotating display of title, definition, examples +- Configurable data sources via JSON files +- Multi-line text wrapping for long content + +API Version: 1.0.0 +""" + +import os +import json +import time +from datetime import date +from typing import Dict, Any, List, Optional, Tuple +from PIL import Image, ImageDraw, ImageFont +from pathlib import Path + +from src.plugin_system.base_plugin import BasePlugin +from src.logging_config import get_logger + +logger = get_logger(__name__) + + +class OfTheDayPlugin(BasePlugin): + """ + Of The Day plugin for displaying daily featured content. + + Supports multiple categories with rotation between title, subtitle, and content. + + Configuration options: + categories (dict): Dictionary of category configurations + category_order (list): Order to display categories + display_rotate_interval (float): Seconds between display rotations + subtitle_rotate_interval (float): Seconds between subtitle rotations + update_interval (float): Seconds between checking for new day + """ + + def __init__(self, plugin_id: str, config: Dict[str, Any], + display_manager, cache_manager, plugin_manager): + """Initialize the of-the-day plugin.""" + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + + # Configuration + self.update_interval = self._get_positive_float(config, 'update_interval', 3600) + self.display_rotate_interval = self._get_positive_float(config, 'display_rotate_interval', 20) + self.subtitle_rotate_interval = self._get_positive_float(config, 'subtitle_rotate_interval', 10) + + # Categories + self.categories = config.get('categories', {}) + self.category_order = config.get('category_order', []) + + # State + self.current_day = None + self.current_items = {} + self.current_category_index = 0 + self.rotation_state = 0 # 0 = title, 1 = content + self.last_update = 0 + self.last_rotation_time = time.time() + self.last_category_rotation_time = time.time() + + # Display state tracking (to avoid unnecessary redraws) + self.last_displayed_category = None + self.last_displayed_rotation_state = None + self.display_needs_update = True # Force initial display + + # Data files + self.data_files = {} + + # Colors + self.title_color = (255, 255, 255) + self.subtitle_color = (220, 220, 220) + self.content_color = (255, 220, 0) # yellow — crisp & distinct from white title + self.background_color = (0, 0, 0) + + # Cached fonts (loaded once to avoid per-frame disk I/O on Pi) + self._title_font: Optional[ImageFont.ImageFont] = None + self._body_font: Optional[ImageFont.ImageFont] = None + + # Load data files + self._load_data_files() + + # Load today's items + self._load_todays_items() + + # Register fonts + self._register_fonts() + + self.logger.info(f"Of The Day plugin initialized with {len(self.current_items)} categories") + + def _get_positive_float(self, config: Dict[str, Any], key: str, default: float) -> float: + """Coerce a config value to a positive float, falling back to default on invalid input.""" + value = config.get(key, default) + try: + value_f = float(value) + except (TypeError, ValueError): + self.logger.warning(f"Invalid {key}='{value}', using default {default}") + return default + if value_f <= 0: + self.logger.warning(f"{key} must be > 0, using default {default}") + return default + return value_f + + def _get_fonts(self) -> Tuple[ImageFont.ImageFont, ImageFont.ImageFont]: + """Return cached title and body fonts, loading them on first call.""" + if self._title_font is None: + try: + self._title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) + except OSError as e: + self.logger.warning(f"Failed to load PressStart2P font: {e}, using fallback") + self._title_font = ( + self.display_manager.small_font + if hasattr(self.display_manager, 'small_font') + else ImageFont.load_default() + ) + if self._body_font is None: + # 4x6 BDF: 6px tall → fits 3 lines in body area (vs 2 for 5x7). + # Crisp 1-bit rendering; draw_text()/get_text_width() handle freetype.Face. + self._body_font = getattr(self.display_manager, 'bdf_4x6_font', None) + if self._body_font is None: + self.logger.warning("bdf_4x6_font unavailable, falling back") + self._body_font = getattr(self.display_manager, 'bdf_5x7_font', None) + if self._body_font is None: + self._body_font = getattr(self.display_manager, 'extra_small_font', + ImageFont.load_default()) + return self._title_font, self._body_font + + def _register_fonts(self) -> None: + """Register fonts with the font manager.""" + try: + if not hasattr(self.plugin_manager, 'font_manager'): + return + + font_manager = self.plugin_manager.font_manager + + font_manager.register_manager_font( + manager_id=self.plugin_id, + element_key=f"{self.plugin_id}.title", + family="press_start", + size_px=8, + color=self.title_color + ) + + font_manager.register_manager_font( + manager_id=self.plugin_id, + element_key=f"{self.plugin_id}.content", + family="four_by_six", + size_px=6, + color=self.content_color + ) + + self.logger.info("Of The Day fonts registered") + except Exception as e: + self.logger.warning(f"Error registering fonts: {e}") + + def _load_data_files(self): + """Load all data files for enabled categories. + + Merges two sources: + 1. Explicitly configured categories from self.categories (has data_file path). + 2. Categories listed in self.category_order that are missing from self.categories + — these are auto-discovered using the conventional path + ``of_the_day/.json`` so that files uploaded before a + config entry was created are still loaded. + """ + # Build a unified view: explicit config takes priority; missing entries + # fall back to the auto-discovered convention. + categories_to_load = {} + for category_name in self.category_order: + if category_name in self.categories: + categories_to_load[category_name] = self.categories[category_name] + else: + # Category is in the display order but has no config entry yet. + # Derive the data_file path from the category name. + self.logger.debug( + f"Category '{category_name}' in category_order but missing from " + f"categories config — auto-discovering data file." + ) + categories_to_load[category_name] = { + 'enabled': True, + 'data_file': f'of_the_day/{category_name}.json', + 'display_name': category_name.replace('_', ' ').title(), + } + # Also include any categories that are in self.categories but not in + # category_order (they won't be displayed but we load them for safety). + for category_name, category_config in self.categories.items(): + if category_name not in categories_to_load: + categories_to_load[category_name] = category_config + + for category_name, category_config in categories_to_load.items(): + if not category_config.get('enabled', True): + self.logger.debug(f"Skipping disabled category: {category_name}") + continue + + data_file = category_config.get('data_file') + if not data_file: + self.logger.warning(f"No data file specified for category: {category_name}") + continue + + try: + # Try to locate the data file + file_path = self._find_data_file(data_file) + if not file_path: + self.logger.warning(f"Could not find data file: {data_file}") + continue + + # Load and parse JSON + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + self.data_files[category_name] = data + self.logger.info(f"Loaded data for category '{category_name}': {len(data)} entries") + + except Exception as e: + self.logger.error(f"Error loading data file for {category_name}: {e}") + + def _find_data_file(self, data_file: str) -> Optional[str]: + """Find the data file in possible locations.""" + # Get plugin directory + plugin_dir = os.path.dirname(os.path.abspath(__file__)) + + # Possible paths to check (prioritize plugin directory) + possible_paths = [ + os.path.join(plugin_dir, data_file), # In plugin directory (preferred) + data_file, # Direct path (if absolute) + os.path.join(os.getcwd(), data_file), # Relative to cwd (fallback) + ] + + for path in possible_paths: + if os.path.exists(path): + self.logger.info(f"Found data file at: {path}") + return path + + self.logger.warning(f"Data file not found: {data_file}") + return None + + def _load_todays_items(self): + """Load items for today's date from all enabled categories.""" + today = date.today() + + if self.current_day == today and self.current_items: + return # Already loaded for today + + self.current_day = today + self.current_items = {} + self.display_needs_update = True # Force redraw when day changes + + # Calculate day of year (1-365, or 1-366 for leap years) + day_of_year = today.timetuple().tm_yday + + for category_name, data in self.data_files.items(): + try: + # Find today's entry using day of year + day_key = str(day_of_year) + + if day_key in data: + self.current_items[category_name] = data[day_key] + item_title = data[day_key].get('word', data[day_key].get('title', 'N/A')) + self.logger.info(f"Loaded item for {category_name} (day {day_of_year}): {item_title}") + else: + self.logger.warning(f"No entry found for day {day_of_year} in category {category_name}") + + except Exception as e: + self.logger.error(f"Error loading today's item for {category_name}: {e}") + + def update(self) -> None: + """Update items if it's a new day.""" + current_time = time.time() + + # Check if we need to update + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + # Check if it's a new day + today = date.today() + if self.current_day != today: + self.logger.info(f"New day detected, loading items for {today}") + self._load_todays_items() + + def display(self, force_clear: bool = False) -> None: + """ + Display of-the-day content. + + Args: + force_clear: If True, clear display before rendering + """ + if not self.current_items: + if self.last_displayed_category != "NO_DATA": + self.last_displayed_category = "NO_DATA" + self._display_no_data() + return + + try: + # Get enabled categories in order + enabled_categories = [cat for cat in self.category_order + if cat in self.current_items and + self.categories.get(cat, {}).get('enabled', True)] + + if not enabled_categories: + if self.last_displayed_category != "NO_DATA": + self.last_displayed_category = "NO_DATA" + self._display_no_data() + return + + # Rotate categories + current_time = time.time() + category_changed = False + if current_time - self.last_category_rotation_time >= self.display_rotate_interval: + self.current_category_index = (self.current_category_index + 1) % len(enabled_categories) + self.last_category_rotation_time = current_time + self.rotation_state = 0 # Reset rotation when changing categories + self.last_rotation_time = current_time + category_changed = True + self.display_needs_update = True + + # Get current category + category_name = enabled_categories[self.current_category_index] + category_config = self.categories.get(category_name, {}) + item_data = self.current_items.get(category_name, {}) + + # Rotate display content + rotation_changed = False + if current_time - self.last_rotation_time >= self.subtitle_rotate_interval: + self.rotation_state = (self.rotation_state + 1) % 2 + self.last_rotation_time = current_time + rotation_changed = True + self.display_needs_update = True + + # Check if we need to update the display + # Only redraw if category changed, rotation state changed, or force_clear + if (self.display_needs_update or + force_clear or + category_changed or + rotation_changed or + self.last_displayed_category != category_name or + self.last_displayed_rotation_state != self.rotation_state): + + # Update tracking state + self.last_displayed_category = category_name + self.last_displayed_rotation_state = self.rotation_state + self.display_needs_update = False + + # Display based on rotation state + if self.rotation_state == 0: + self._display_title(category_config, item_data) + else: + self._display_content(category_config, item_data) + + except Exception as e: + self.logger.error(f"Error displaying of-the-day: {e}") + if self.last_displayed_category != "ERROR": + self.last_displayed_category = "ERROR" + self._display_error() + + def _wrap_text(self, text: str, max_width: int, font, max_lines: int = 10) -> List[str]: + """Wrap text to fit within max_width, similar to old manager.""" + if not text: + return [""] + lines = [] + current_line = [] + words = text.split() + for word in words: + test_line = ' '.join(current_line + [word]) if current_line else word + try: + text_width = self.display_manager.get_text_width(test_line, font) + except Exception: + # Fallback calculation + if isinstance(font, ImageFont.ImageFont): + bbox = font.getbbox(test_line) + text_width = bbox[2] - bbox[0] + else: + text_width = len(test_line) * 6 + if text_width <= max_width: + current_line.append(word) + else: + if current_line: + lines.append(' '.join(current_line)) + current_line = [word] + else: + # Word is too long - truncate it + truncated = word + while len(truncated) > 0: + try: + test_width = self.display_manager.get_text_width(truncated + "...", font) + except Exception: + if isinstance(font, ImageFont.ImageFont): + bbox = font.getbbox(truncated + "...") + test_width = bbox[2] - bbox[0] + else: + test_width = len(truncated + "...") * 6 + if test_width <= max_width: + lines.append(truncated + "...") + break + truncated = truncated[:-1] + if not truncated: + lines.append(word[:10] + "...") + if len(lines) >= max_lines: + break + if current_line and len(lines) < max_lines: + lines.append(' '.join(current_line)) + return lines[:max_lines] + + def _draw_bdf_text(self, draw, font, text: str, x: int, y: int, color: tuple = (255, 255, 255)): + """Draw text supporting both BDF (FreeType Face) and PIL TTF fonts, similar to old manager.""" + self.logger.debug(f"_draw_bdf_text: text='{text}', x={x}, y={y}, font={type(font).__name__}, color={color}") + try: + # If we have a PIL font, use native text rendering + if isinstance(font, ImageFont.ImageFont): + draw.text((x, y), text, fill=color, font=font) + self.logger.debug(f"PIL text drawn: '{text}'") + return + + # Try to import freetype + try: + import freetype + except ImportError: + # If freetype not available, fallback to PIL + draw.text((x, y), text, fill=color, font=ImageFont.load_default()) + return + + # For BDF fonts (FreeType Face) + if isinstance(font, freetype.Face): + # Compute baseline from font ascender so caller can pass top-left y + try: + ascender_px = font.size.ascender >> 6 + except Exception: + ascender_px = 0 + baseline_y = y + ascender_px + + # Render BDF glyphs manually + current_x = x + for char in text: + font.load_char(char) + bitmap = font.glyph.bitmap + + # Get glyph metrics + glyph_left = font.glyph.bitmap_left + glyph_top = font.glyph.bitmap_top + + for i in range(bitmap.rows): + for j in range(bitmap.width): + try: + byte_index = i * bitmap.pitch + (j // 8) + if byte_index < len(bitmap.buffer): + byte = bitmap.buffer[byte_index] + if byte & (1 << (7 - (j % 8))): + # Calculate actual pixel position + pixel_x = current_x + glyph_left + j + pixel_y = baseline_y - glyph_top + i + # Only draw if within bounds + if (0 <= pixel_x < self.display_manager.width and + 0 <= pixel_y < self.display_manager.height): + draw.point((pixel_x, pixel_y), fill=color) + except IndexError: + continue + current_x += font.glyph.advance.x >> 6 + except Exception as e: + self.logger.error(f"Error in _draw_bdf_text for text '{text}' at ({x}, {y}): {e}", exc_info=True) + # Fallback to simple text drawing + try: + draw.text((x, y), text, fill=color, font=ImageFont.load_default()) + except Exception as fallback_e: + self.logger.error(f"Fallback text drawing also failed: {fallback_e}", exc_info=True) + + def _display_title(self, category_config: Dict, item_data: Dict): + """Display the title/word with subtitle, matching old manager layout.""" + # Clear display first + self.display_manager.clear() + + # Use display_manager's image and draw directly + draw = self.display_manager.draw + + # Load fonts - match old manager font usage + try: + title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) + except Exception as e: + self.logger.warning(f"Failed to load PressStart2P font: {e}, using fallback") + title_font = self.display_manager.small_font if hasattr(self.display_manager, 'small_font') else ImageFont.load_default() + + body_font = getattr(self.display_manager, 'bdf_4x6_font', None) + if body_font is None: + body_font = getattr(self.display_manager, 'bdf_5x7_font', None) + if body_font is None: + body_font = getattr(self.display_manager, 'extra_small_font', + ImageFont.load_default()) + + # Get font heights + try: + title_height = self.display_manager.get_font_height(title_font) + except Exception as e: + self.logger.warning(f"Error getting title font height: {e}, using default 8") + title_height = 8 + try: + body_height = self.display_manager.get_font_height(body_font) + except Exception as e: + self.logger.warning(f"Error getting body font height: {e}, using default 8") + body_height = 8 + + # Per-category color overrides (fall back to plugin-wide defaults) + title_color = tuple(category_config.get('title_color', list(self.title_color))) + subtitle_color = tuple(category_config.get('subtitle_color', list(self.subtitle_color))) + + # Layout matching old manager: raise 5px so bottom text isn't clipped + margin_top = 3 + margin_bottom = 1 + underline_space = 1 + + # Get title/word (JSON uses "title" not "word") + title = item_data.get('title', item_data.get('word', 'N/A')) + + # Get subtitle (JSON uses "subtitle") + subtitle = item_data.get('subtitle', item_data.get('pronunciation', item_data.get('type', ''))) + + # Calculate title width for centering + try: + title_width = self.display_manager.get_text_width(title, title_font) + except Exception as e: + self.logger.warning(f"Error calculating title width using display_manager: {e}, trying fallback") + if isinstance(title_font, ImageFont.ImageFont): + bbox = title_font.getbbox(title) + title_width = bbox[2] - bbox[0] + else: + title_width = len(title) * 6 + + # Center the title horizontally + title_x = (self.display_manager.width - title_width) // 2 + title_y = margin_top + + # Draw title using display_manager.draw_text (proper method) + self.logger.info(f"Drawing title '{title}' at ({title_x}, {title_y}) with font type {type(title_font).__name__}") + try: + self.display_manager.draw_text( + title, + x=title_x, + y=title_y, + color=title_color, + font=title_font + ) + self.logger.debug(f"Title '{title}' drawn using display_manager.draw_text") + except Exception as e: + self.logger.error(f"Error drawing title '{title}': {e}", exc_info=True) + + # Draw underline below title (like old manager) + underline_y = title_y + title_height + 1 + underline_x_start = title_x + underline_x_end = title_x + title_width + draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], + fill=title_color, width=1) + + # Draw subtitle below underline (centered, like old manager) + if subtitle: + # Wrap subtitle text if needed + available_width = self.display_manager.width - 4 + wrapped_subtitle_lines = self._wrap_text(subtitle, available_width, body_font, max_lines=3) + actual_subtitle_lines = [line for line in wrapped_subtitle_lines if line.strip()] + + if actual_subtitle_lines: + # Calculate spacing - similar to old manager's dynamic spacing + total_subtitle_height = len(actual_subtitle_lines) * body_height + available_space = self.display_manager.height - underline_y - margin_bottom + space_after_underline = max(2, (available_space - total_subtitle_height) // 2) + + subtitle_start_y = underline_y + space_after_underline + underline_space + current_y = subtitle_start_y + + for line in actual_subtitle_lines: + if line.strip(): + # Center each line of subtitle + try: + line_width = self.display_manager.get_text_width(line, body_font) + except Exception: + if isinstance(body_font, ImageFont.ImageFont): + bbox = body_font.getbbox(line) + line_width = bbox[2] - bbox[0] + else: + line_width = len(line) * 6 + line_x = (self.display_manager.width - line_width) // 2 + + # Use display_manager.draw_text for subtitle + self.display_manager.draw_text( + line, + x=line_x, + y=current_y, + color=subtitle_color, + font=body_font + ) + current_y += body_height + 1 + + self.display_manager.update_display() + + def _display_content(self, category_config: Dict, item_data: Dict): + """Display the definition/content, matching old manager layout.""" + # Clear display first + self.display_manager.clear() + + # Use display_manager's image and draw directly + draw = self.display_manager.draw + + # Load fonts (cached after first call to avoid per-frame disk I/O) + title_font, body_font = self._get_fonts() + + # Get font heights + try: + title_height = self.display_manager.get_font_height(title_font) + except Exception: + title_height = 8 + try: + body_height = self.display_manager.get_font_height(body_font) + except Exception: + body_height = 8 + + # Per-category color overrides (fall back to plugin-wide defaults) + title_color = tuple(category_config.get('title_color', list(self.title_color))) + content_color = tuple(category_config.get('content_color', list(self.content_color))) + + # Layout matching old manager: raise 5px so bottom text isn't clipped + margin_top = 3 + margin_bottom = 1 + underline_space = 1 + + # Get title/word (JSON uses "title") + title = item_data.get('title', item_data.get('word', 'N/A')) + self.logger.debug(f"Displaying content for title: {title}") + + # Get description (JSON uses "description") + description = item_data.get('description', item_data.get('definition', item_data.get('content', item_data.get('text', 'No content')))) + + # Calculate title width for centering (for underline placement) + try: + title_width = self.display_manager.get_text_width(title, title_font) + except Exception: + if isinstance(title_font, ImageFont.ImageFont): + bbox = title_font.getbbox(title) + title_width = bbox[2] - bbox[0] + else: + title_width = len(title) * 6 + + # Center the title horizontally (same position as in _display_title) + title_x = (self.display_manager.width - title_width) // 2 + title_y = margin_top + + # Draw title using display_manager.draw_text (same as title screen) + self.display_manager.draw_text( + title, + x=title_x, + y=title_y, + color=title_color, + font=title_font + ) + + # Draw underline below title (same as title screen) + underline_y = title_y + title_height + 1 + underline_x_start = title_x + underline_x_end = title_x + title_width + draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], + fill=title_color, width=1) + + # Wrap description text + available_width = self.display_manager.width - 4 + max_lines = 10 + wrapped_lines = self._wrap_text(description, available_width, body_font, max_lines=max_lines) + actual_body_lines = [line for line in wrapped_lines if line.strip()] + + if actual_body_lines: + # Calculate dynamic spacing - similar to old manager + num_body_lines = len(actual_body_lines) + body_content_height = num_body_lines * body_height + available_space = self.display_manager.height - underline_y - margin_bottom + + if body_content_height < available_space: + # Distribute extra space: some after underline, rest between lines + extra_space = available_space - body_content_height + space_after_underline = max(1, int(extra_space * 0.15)) + space_between_lines = max(1, int(extra_space * 0.7 / max(1, num_body_lines - 1))) if num_body_lines > 1 else 0 + else: + # Tight spacing — minimize gap to fit max lines with 4x6 font + space_after_underline = 1 + space_between_lines = 1 + + # Draw body text with dynamic spacing + body_start_y = underline_y + space_after_underline + underline_space - 1 # -1 shifts body 2px up vs old manager + current_y = body_start_y + + for i, line in enumerate(actual_body_lines): + if line.strip(): + # Center each line of body text (like old manager) + try: + line_width = self.display_manager.get_text_width(line, body_font) + except Exception: + if isinstance(body_font, ImageFont.ImageFont): + bbox = body_font.getbbox(line) + line_width = bbox[2] - bbox[0] + else: + line_width = len(line) * 6 + line_x = (self.display_manager.width - line_width) // 2 + + # Use display_manager.draw_text for description + self.display_manager.draw_text( + line, + x=line_x, + y=current_y, + color=content_color, + font=body_font + ) + + # Move to next line position + if i < len(actual_body_lines) - 1: # Not the last line + current_y += body_height + space_between_lines + + self.display_manager.update_display() + + def _display_no_data(self): + """Display message when no data is available.""" + self.display_manager.clear() + img = Image.new('RGB', (self.display_manager.width, + self.display_manager.height), + self.background_color) + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 8) + except OSError as e: + self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") + font = ImageFont.load_default() + + draw.text((5, 12), "No Data", font=font, fill=(200, 200, 200)) + + self.display_manager.image = img.copy() + self.display_manager.update_display() + + def _display_error(self): + """Display error message.""" + self.display_manager.clear() + img = Image.new('RGB', (self.display_manager.width, + self.display_manager.height), + self.background_color) + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 8) + except OSError as e: + self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") + font = ImageFont.load_default() + + draw.text((5, 12), "Error", font=font, fill=(255, 0, 0)) + + self.display_manager.image = img.copy() + self.display_manager.update_display() + + def get_display_duration(self) -> float: + """Get display duration from config.""" + return self.config.get('display_duration', 40.0) + + def get_info(self) -> Dict[str, Any]: + """Return plugin info for web UI.""" + info = super().get_info() + info.update({ + 'current_day': str(self.current_day) if self.current_day else None, + 'categories_loaded': len(self.current_items), + 'enabled_categories': [cat for cat in self.category_order + if self.categories.get(cat, {}).get('enabled', True)] + }) + return info + + def on_config_change(self, config: Dict[str, Any]) -> None: + """Handle configuration changes (called when user updates config via web UI).""" + self.logger.info("Config changed, reloading categories") + + # Update configuration + self.config = config + self.update_interval = self._get_positive_float(config, 'update_interval', 3600) + self.display_rotate_interval = self._get_positive_float(config, 'display_rotate_interval', 20) + self.subtitle_rotate_interval = self._get_positive_float(config, 'subtitle_rotate_interval', 10) + self.categories = config.get('categories', {}) + self.category_order = config.get('category_order', []) + + # Reset state + self.current_category_index = 0 + self.rotation_state = 0 + self.display_needs_update = True + + # Reload data files (respects enabled status) + self.data_files = {} + self._load_data_files() + + # Reload today's items + self.current_day = None # Force reload + self._load_todays_items() + + self.logger.info(f"Config reloaded: {len(self.data_files)} categories enabled") + + def cleanup(self) -> None: + """Cleanup resources.""" + self.current_items = {} + self.data_files = {} + self.logger.info("Of The Day plugin cleaned up") + diff --git a/plugin-repos/of-the-day/scripts/save_file.py b/plugin-repos/of-the-day/scripts/save_file.py new file mode 100644 index 000000000..eb6476ff1 --- /dev/null +++ b/plugin-repos/of-the-day/scripts/save_file.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Save updated content to a JSON file in the of_the_day directory. +Validates the JSON structure before saving. +""" + +import os +import json +import sys +from pathlib import Path + +# Get plugin directory (scripts/ -> plugin root) +plugin_dir = Path(__file__).parent.parent +data_dir = plugin_dir / 'of_the_day' +data_dir.mkdir(parents=True, exist_ok=True) + +try: + input_data = json.load(sys.stdin) + filename = input_data.get('filename', '') + content_str = input_data.get('content', '') + + if not filename or not content_str: + print(json.dumps({ + 'status': 'error', + 'message': 'Filename and content are required' + })) + sys.exit(1) + + # Security: ensure filename doesn't contain path traversal + if '..' in filename or '/' in filename or '\\' in filename: + print(json.dumps({ + 'status': 'error', + 'message': 'Invalid filename' + })) + sys.exit(1) + + if not filename.endswith('.json'): + print(json.dumps({ + 'status': 'error', + 'message': 'File must be a JSON file (.json)' + })) + sys.exit(1) + + # Validate JSON + try: + content = json.loads(content_str) + except json.JSONDecodeError as e: + print(json.dumps({ + 'status': 'error', + 'message': f'Invalid JSON: {str(e)}' + })) + sys.exit(1) + + # Validate structure (must be an object with day number keys 1-365) + if not isinstance(content, dict): + print(json.dumps({ + 'status': 'error', + 'message': 'JSON must be an object with day numbers (1-365) as keys' + })) + sys.exit(1) + + # Check if keys are valid day numbers + for key in content.keys(): + try: + day_num = int(key) + if day_num < 1 or day_num > 365: + print(json.dumps({ + 'status': 'error', + 'message': f'Day number {day_num} is out of range (must be 1-365)' + })) + sys.exit(1) + except ValueError: + print(json.dumps({ + 'status': 'error', + 'message': f'Invalid key "{key}": all keys must be day numbers (1-365)' + })) + sys.exit(1) + + # Save file + file_path = data_dir / filename + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(content, f, indent=2, ensure_ascii=False) + + print(json.dumps({ + 'status': 'success', + 'message': f'File {filename} saved successfully' + })) + +except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + diff --git a/plugin-repos/of-the-day/scripts/toggle_category.py b/plugin-repos/of-the-day/scripts/toggle_category.py new file mode 100644 index 000000000..8917c619e --- /dev/null +++ b/plugin-repos/of-the-day/scripts/toggle_category.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Toggle a category's enabled status. +Receives category_name and optional enabled state via stdin as JSON. +""" + +import os +import re +import json +import sys +from pathlib import Path + +LEDMATRIX_ROOT = os.environ.get('LEDMATRIX_ROOT', os.getcwd()) +config_file = Path(LEDMATRIX_ROOT) / 'config' / 'config.json' + +# Read params from stdin +try: + stdin_input = sys.stdin.read().strip() + if stdin_input: + params = json.loads(stdin_input) + else: + params = {} +except (json.JSONDecodeError, ValueError) as e: + print(json.dumps({ + 'status': 'error', + 'message': f'Invalid JSON input: {str(e)}' + })) + sys.exit(1) + +category_name = params.get('category_name') +if not category_name: + print(json.dumps({ + 'status': 'error', + 'message': 'category_name is required' + })) + sys.exit(1) + +if not re.fullmatch(r'[a-z0-9_-]+', category_name, flags=re.IGNORECASE): + print(json.dumps({ + 'status': 'error', + 'message': 'category_name must contain only letters, numbers, "_" or "-"' + })) + sys.exit(1) + +# Load current config +config = {} +try: + if config_file.exists(): + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) +except (json.JSONDecodeError, ValueError) as e: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to load config: {str(e)}' + })) + sys.exit(1) + +# Get plugin config +plugin_config = config.get('of-the-day', {}) +categories = plugin_config.get('categories', {}) + +# If category isn't in config yet (e.g. a manually-placed file), auto-register it +# so it can be toggled immediately without needing a re-upload. +if category_name not in categories: + plugin_dir = Path(__file__).parent.parent + data_file = f'of_the_day/{category_name}.json' + display_name = category_name.replace('_', ' ').title() + categories[category_name] = { + 'enabled': True, + 'data_file': data_file, + 'display_name': display_name + } + # Also add to category_order if missing + category_order = plugin_config.get('category_order', []) + if category_name not in category_order: + category_order.append(category_name) + plugin_config['category_order'] = category_order + +# Determine new enabled state +if 'enabled' in params: + # Explicit state provided — accept bool or "true"/"false" string + enabled_value = params['enabled'] + if isinstance(enabled_value, bool): + new_enabled = enabled_value + elif isinstance(enabled_value, str) and enabled_value.lower() in ('true', 'false'): + new_enabled = enabled_value.lower() == 'true' + else: + print(json.dumps({ + 'status': 'error', + 'message': 'enabled must be a boolean or "true"/"false" string' + })) + sys.exit(1) +else: + # Toggle current state + current_enabled = categories[category_name].get('enabled', True) + new_enabled = not current_enabled + +# Update the category +categories[category_name]['enabled'] = new_enabled +plugin_config['categories'] = categories +config['of-the-day'] = plugin_config + +# Save config +try: + config_file.parent.mkdir(parents=True, exist_ok=True) + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) +except (OSError, TypeError) as e: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to save config: {str(e)}' + })) + sys.exit(1) + +print(json.dumps({ + 'status': 'success', + 'message': f'Category "{category_name}" {"enabled" if new_enabled else "disabled"}', + 'category_name': category_name, + 'enabled': new_enabled +})) diff --git a/plugin-repos/of-the-day/scripts/upload_file.py b/plugin-repos/of-the-day/scripts/upload_file.py new file mode 100644 index 000000000..6912184e3 --- /dev/null +++ b/plugin-repos/of-the-day/scripts/upload_file.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Upload a JSON file to the of_the_day directory. +Validates the file format and automatically adds category to config. +""" + +import os +import json +import sys +from pathlib import Path + +# Get plugin directory (scripts/ -> plugin root) +plugin_dir = Path(__file__).parent.parent +data_dir = plugin_dir / 'of_the_day' +data_dir.mkdir(parents=True, exist_ok=True) + +# Read JSON from stdin +try: + input_data = json.load(sys.stdin) + filename = input_data.get('filename', '') + content = input_data.get('content', '') + + if not filename or not content: + print(json.dumps({ + 'status': 'error', + 'message': 'Filename and content are required' + })) + sys.exit(1) + + # Validate filename + if not filename.endswith('.json'): + print(json.dumps({ + 'status': 'error', + 'message': 'File must be a JSON file (.json)' + })) + sys.exit(1) + + if '..' in filename or '/' in filename or '\\' in filename: + print(json.dumps({ + 'status': 'error', + 'message': 'Invalid filename' + })) + sys.exit(1) + + # Validate JSON content + try: + data = json.loads(content) + except json.JSONDecodeError as e: + print(json.dumps({ + 'status': 'error', + 'message': f'Invalid JSON: {str(e)}' + })) + sys.exit(1) + + # Validate structure (must be an object with day number keys 1-365) + if not isinstance(data, dict): + print(json.dumps({ + 'status': 'error', + 'message': 'JSON must be an object with day numbers (1-365) as keys' + })) + sys.exit(1) + + # Check if keys are valid day numbers + for key in data.keys(): + try: + day_num = int(key) + if day_num < 1 or day_num > 365: + print(json.dumps({ + 'status': 'error', + 'message': f'Day number {day_num} is out of range (must be 1-365)' + })) + sys.exit(1) + except ValueError: + print(json.dumps({ + 'status': 'error', + 'message': f'Invalid key "{key}": all keys must be day numbers (1-365)' + })) + sys.exit(1) + + # Save file + file_path = data_dir / filename + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Extract category name and update config + category_name = filename.replace('.json', '') + display_name = input_data.get('display_name', category_name.replace('_', ' ').title()) + + # Update config + sys.path.insert(0, str(plugin_dir)) + from scripts.update_config import add_category_to_config + add_category_to_config(category_name, f'of_the_day/{filename}', display_name) + + print(json.dumps({ + 'status': 'success', + 'message': f'File {filename} uploaded successfully', + 'filename': filename, + 'category_name': category_name + })) + +except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + diff --git a/plugin-repos/of-the-day/web_ui/file_manager.html b/plugin-repos/of-the-day/web_ui/file_manager.html new file mode 100644 index 000000000..05adfa553 --- /dev/null +++ b/plugin-repos/of-the-day/web_ui/file_manager.html @@ -0,0 +1,1049 @@ +
+ + + + + + +
+
+

File Explorer

+

Manage JSON data files in of_the_day/ directory

+
+ +
+ + +
+ +

Drag and drop JSON files here

+

or click to browse • Keys must be day numbers (1-365)

+ +
+ + +
+
+
+

Loading files...

+
+
+ + + + + + + + + + + + + + +
diff --git a/plugin-repos/web-ui-info/manager.py b/plugin-repos/web-ui-info/manager.py index ea75f645a..b841ec24a 100644 --- a/plugin-repos/web-ui-info/manager.py +++ b/plugin-repos/web-ui-info/manager.py @@ -7,7 +7,6 @@ API Version: 1.0.0 """ -import logging import os import socket import subprocess @@ -17,8 +16,9 @@ from PIL import Image, ImageDraw, ImageFont from src.plugin_system.base_plugin import BasePlugin +from src.logging_config import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class WebUIInfoPlugin(BasePlugin): @@ -301,7 +301,7 @@ def display(self, force_clear: bool = False) -> None: try: self.display_manager.clear() self.display_manager.update_display() - except: + except Exception: pass def get_display_duration(self) -> float: diff --git a/src/display_manager.py b/src/display_manager.py index ee7a1522b..6e1f4e6fb 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -389,6 +389,7 @@ def _load_fonts(self): # Load with freetype for proper BDF handling face = freetype.Face(self.calendar_font_path) + face.set_pixel_sizes(0, 7) # Initialize size metrics for BDF logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}") logger.info(f"Calendar font size: {face.size.height >> 6} pixels") @@ -405,6 +406,19 @@ def _load_fonts(self): self.bdf_5x7_font = self.calendar_font logger.info(f"Assigned calendar_font (type: {type(self.bdf_5x7_font).__name__}) to bdf_5x7_font.") + # Load 4x6 BDF font for compact body text (of-the-day descriptions, etc.) + try: + bdf_4x6_path = "assets/fonts/4x6.bdf" + if not os.path.exists(bdf_4x6_path): + raise FileNotFoundError(f"Font file not found at {bdf_4x6_path}") + face_4x6 = freetype.Face(bdf_4x6_path) + face_4x6.set_pixel_sizes(0, 6) + self.bdf_4x6_font = face_4x6 + logger.info("4x6 BDF font loaded successfully") + except Exception as font_err: + logger.error(f"Failed to load 4x6 BDF font: {font_err}") + self.bdf_4x6_font = getattr(self, 'bdf_5x7_font', self.small_font) + # Load 4x6 font as extra_small_font try: font_path = "assets/fonts/4x6-font.ttf" @@ -427,6 +441,8 @@ def _load_fonts(self): self.extra_small_font = self.regular_font if not hasattr(self, 'bdf_5x7_font'): # Ensure bdf_5x7_font also gets a fallback self.bdf_5x7_font = self.regular_font + if not hasattr(self, 'bdf_4x6_font'): + self.bdf_4x6_font = self.regular_font def get_text_width(self, text, font): diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000..66173aec4 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +# Test package diff --git a/test/test_config_validation_edge_cases.py b/test/test_config_validation_edge_cases.py index 6cddb1fe1..6de4d2ad3 100644 --- a/test/test_config_validation_edge_cases.py +++ b/test/test_config_validation_edge_cases.py @@ -23,6 +23,7 @@ sys.path.insert(0, str(project_root)) from src.config_manager import ConfigManager +from src.exceptions import ConfigError from src.plugin_system.schema_manager import SchemaManager @@ -30,35 +31,31 @@ class TestInvalidJson: """Test handling of invalid JSON in config files.""" def test_invalid_json_syntax(self, tmp_path): - """Config with invalid JSON syntax should be handled gracefully.""" + """Config with invalid JSON syntax should raise ConfigError.""" config_file = tmp_path / "config.json" config_file.write_text("{ invalid json }") - with patch.object(ConfigManager, '_get_config_path', return_value=str(config_file)): - config_manager = ConfigManager(config_dir=str(tmp_path)) - # Should not raise, should return empty or default config - config = config_manager.load_config() - assert isinstance(config, dict) + config_manager = ConfigManager(config_path=str(config_file)) + with pytest.raises(ConfigError): + config_manager.load_config() def test_truncated_json(self, tmp_path): - """Config with truncated JSON should be handled gracefully.""" + """Config with truncated JSON should raise ConfigError.""" config_file = tmp_path / "config.json" config_file.write_text('{"plugin": {"enabled": true') # Missing closing braces - with patch.object(ConfigManager, '_get_config_path', return_value=str(config_file)): - config_manager = ConfigManager(config_dir=str(tmp_path)) - config = config_manager.load_config() - assert isinstance(config, dict) + config_manager = ConfigManager(config_path=str(config_file)) + with pytest.raises(ConfigError): + config_manager.load_config() def test_empty_config_file(self, tmp_path): - """Empty config file should be handled gracefully.""" + """Empty config file should raise ConfigError.""" config_file = tmp_path / "config.json" config_file.write_text("") - with patch.object(ConfigManager, '_get_config_path', return_value=str(config_file)): - config_manager = ConfigManager(config_dir=str(tmp_path)) - config = config_manager.load_config() - assert isinstance(config, dict) + config_manager = ConfigManager(config_path=str(config_file)) + with pytest.raises(ConfigError): + config_manager.load_config() class TestTypeValidation: diff --git a/test/test_display_controller.py b/test/test_display_controller.py index 9deafd0d7..0f23abcb6 100644 --- a/test/test_display_controller.py +++ b/test/test_display_controller.py @@ -209,11 +209,12 @@ class TestDisplayControllerSchedule: def test_schedule_disabled(self, test_display_controller): """Test when schedule is disabled.""" controller = test_display_controller - controller.config = {"schedule": {"enabled": False}} - + schedule_config = {"schedule": {"enabled": False}} + controller.config_service.get_config.return_value = schedule_config + controller._check_schedule() assert controller.is_display_active is True - + def test_active_hours(self, test_display_controller): """Test active hours check.""" controller = test_display_controller @@ -222,15 +223,16 @@ def test_active_hours(self, test_display_controller): mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday" mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time() mock_datetime.strptime = datetime.strptime - - controller.config = { + + schedule_config = { "schedule": { "enabled": True, "start_time": "09:00", "end_time": "17:00" } } - + controller.config_service.get_config.return_value = schedule_config + controller._check_schedule() assert controller.is_display_active is True @@ -242,15 +244,16 @@ def test_inactive_hours(self, test_display_controller): mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday" mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time() mock_datetime.strptime = datetime.strptime - - controller.config = { + + schedule_config = { "schedule": { "enabled": True, "start_time": "09:00", "end_time": "17:00" } } - + controller.config_service.get_config.return_value = schedule_config + controller._check_schedule() assert controller.is_display_active is False diff --git a/test/test_web_api.py b/test/test_web_api.py index f5ad2ed55..b7cd01b7f 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -29,7 +29,11 @@ def mock_config_manager(): } mock.get_config_path.return_value = 'config/config.json' mock.get_secrets_path.return_value = 'config/config_secrets.json' - mock.get_raw_file_content.return_value = {'weather': {'api_key': 'test'}} + mock.get_raw_file_content.return_value = { + 'display': {'brightness': 50}, + 'plugins': {}, + 'timezone': 'UTC' + } mock.save_config_atomic.return_value = MagicMock( status=MagicMock(value='success'), message=None @@ -104,7 +108,7 @@ def test_get_main_config(self, client, mock_config_manager): assert data.get('status') == 'success' assert 'data' in data assert 'display' in data['data'] - mock_config_manager.load_config.assert_called_once() + mock_config_manager.get_raw_file_content.assert_called_once_with('main') def test_save_main_config(self, client, mock_config_manager): """Test saving main configuration.""" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index c49afc246..8463e877f 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -202,12 +202,13 @@ def _stop_display_service(): @api_v3.route('/config/main', methods=['GET']) def get_main_config(): - """Get main configuration""" + """Get main configuration (raw file only — secrets are never included)""" try: if not api_v3.config_manager: return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - config = api_v3.config_manager.load_config() + # Use raw file content to avoid returning secrets that load_config() merges in + config = api_v3.config_manager.get_raw_file_content('main') return jsonify({'status': 'success', 'data': config}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -653,11 +654,6 @@ def save_main_config(): if not data: return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - import logging - logging.error(f"DEBUG: save_main_config received data: {data}") - logging.error(f"DEBUG: Content-Type header: {request.content_type}") - logging.error(f"DEBUG: Headers: {dict(request.headers)}") - # Merge with existing config (similar to original implementation) current_config = api_v3.config_manager.load_config() @@ -896,8 +892,8 @@ def separate_secrets(config, secrets_set, prefix=''): full_path = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): nested_regular, nested_secrets = separate_secrets(value, secrets_set, full_path) - if nested_regular: - regular[key] = nested_regular + # Always preserve the key in regular even if empty (maintains structure) + regular[key] = nested_regular if nested_secrets: secrets[key] = nested_secrets elif full_path in secrets_set: @@ -1015,13 +1011,27 @@ def separate_secrets(config, secrets_set, prefix=''): @api_v3.route('/config/secrets', methods=['GET']) def get_secrets_config(): - """Get secrets configuration""" + """Get secrets configuration (values masked for security)""" try: if not api_v3.config_manager: return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 config = api_v3.config_manager.get_raw_file_content('secrets') - return jsonify({'status': 'success', 'data': config}) + + # Mask all secret values so they are never returned in plain text + def _mask_values(d): + masked = {} + for k, v in d.items(): + if isinstance(v, dict): + masked[k] = _mask_values(v) + elif v not in (None, '') and not (isinstance(v, str) and v.startswith('YOUR_')): + masked[k] = '••••••••' + else: + masked[k] = v + return masked + + masked_config = _mask_values(config) + return jsonify({'status': 'success', 'data': masked_config}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -1621,6 +1631,7 @@ def start_on_demand_display(): # This ensures the request is available when the service starts/restarts cache = _ensure_cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) + now = time.time() request_payload = { 'request_id': request_id, 'action': 'start', @@ -1628,10 +1639,25 @@ def start_on_demand_display(): 'mode': resolved_mode, 'duration': duration, 'pinned': pinned, - 'timestamp': time.time() + 'timestamp': now } cache.set('display_on_demand_request', request_payload) + # Also write display_on_demand_config so that the restarted service loads + # the correct plugin at startup (it reads this key during __init__ to decide + # which plugin to load; a stale entry from a previous session would otherwise + # cause it to load the wrong plugin and reject the real-time request). + config_ttl = min(3600, int(duration)) if duration else 3600 + config_payload = { + 'plugin_id': resolved_plugin, + 'mode': resolved_mode, + 'duration': duration, + 'pinned': pinned, + 'requested_at': now, + 'expires_at': now + config_ttl, + } + cache.set('display_on_demand_config', config_payload, ttl=config_ttl) + # Check if display service is running (or will be started) service_status = _get_display_service_status() service_was_running = service_status.get('active', False) @@ -2594,9 +2620,34 @@ def get_plugin_config(): if not plugin_config: plugin_config = { 'enabled': True, - 'display_duration': 30 + 'display_duration': 15, + 'live_priority': False } + # Mask fields marked x-secret:true so API keys are never sent to the browser. + # The save endpoint (POST /plugins/config) already routes these to config_secrets.json; + # the GET path must not undo that protection by returning the merged secrets value. + if schema_mgr: + try: + schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) + if schema_for_mask and 'properties' in schema_for_mask: + def _mask_secret_fields(cfg, props): + result = dict(cfg) + for fname, fprops in props.items(): + if fprops.get('x-secret', False): + if fname in result and result[fname]: + result[fname] = '' + elif fprops.get('type') == 'object' and 'properties' in fprops: + if fname in result and isinstance(result[fname], dict): + result[fname] = _mask_secret_fields( + result[fname], fprops['properties'] + ) + return result + plugin_config = _mask_secret_fields(plugin_config, schema_for_mask['properties']) + except Exception as mask_err: + import logging as _logging + _logging.warning("Could not mask secret fields for %s: %s", plugin_id, mask_err) + return success_response(data=plugin_config) except Exception as e: from src.web_interface.errors import WebInterfaceError @@ -2620,18 +2671,13 @@ def update_plugin(): # JSON request data, error = validate_request_json(['plugin_id']) if error: - # Log what we received for debugging - print(f"[UPDATE] JSON validation failed. Content-Type: {content_type}") - print(f"[UPDATE] Request data: {request.data}") - print(f"[UPDATE] Request form: {request.form.to_dict()}") + logger.warning(f"Plugin update JSON validation failed. Content-Type: {content_type}") return error else: # Form data or query string plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id') if not plugin_id: - print(f"[UPDATE] Missing plugin_id. Content-Type: {content_type}") - print(f"[UPDATE] Query args: {request.args.to_dict()}") - print(f"[UPDATE] Form data: {request.form.to_dict()}") + logger.warning(f"Plugin update missing plugin_id. Content-Type: {content_type}") return error_response( ErrorCode.INVALID_INPUT, 'plugin_id required', @@ -4652,20 +4698,11 @@ def normalize_config_values(config, schema_props, prefix=''): enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema) plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering) - # Debug logging for union type fields (temporary) - if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}): - seed_value = plugin_config['rotation_settings']['random_seed'] - logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}") - # Validate configuration against schema before saving if schema: - # Log what we're validating for debugging - logger.info(f"Validating config for {plugin_id}") - logger.info(f"Config keys being validated: {list(plugin_config.keys())}") - logger.info(f"Full config: {plugin_config}") + logger.debug(f"Validating config for {plugin_id}, keys: {list(plugin_config.keys())}") # Get enhanced schema keys (including injected core properties) - # We need to create an enhanced schema to get the actual allowed keys import copy enhanced_schema = copy.deepcopy(schema) if "properties" not in enhanced_schema: @@ -4675,31 +4712,19 @@ def normalize_config_values(config, schema_props, prefix=''): core_properties = ["enabled", "display_duration", "live_priority"] for prop_name in core_properties: if prop_name not in enhanced_schema["properties"]: - # Add placeholder to get the full list of allowed keys enhanced_schema["properties"][prop_name] = {"type": "any"} is_valid, validation_errors = schema_mgr.validate_config_against_schema( plugin_config, schema, plugin_id ) if not is_valid: - # Log validation errors for debugging - logger.error(f"Config validation failed for {plugin_id}") - logger.error(f"Validation errors: {validation_errors}") - logger.error(f"Config that failed: {plugin_config}") - logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}") - - # Also print to console for immediate visibility - import json - print(f"[ERROR] Config validation failed for {plugin_id}") - print(f"[ERROR] Validation errors: {validation_errors}") - print(f"[ERROR] Config keys: {list(plugin_config.keys())}") - print(f"[ERROR] Schema property keys: {list(enhanced_schema.get('properties', {}).keys())}") + logger.error(f"Config validation failed for {plugin_id}: {validation_errors}") + logger.error(f"Config keys: {list(plugin_config.keys())}, Schema keys: {list(enhanced_schema.get('properties', {}).keys())}") # Log raw form data if this was a form submission if 'application/json' not in (request.content_type or ''): form_data = request.form.to_dict() - print(f"[ERROR] Raw form data: {json.dumps({k: str(v)[:200] for k, v in form_data.items()}, indent=2)}") - print(f"[ERROR] Parsed config: {json.dumps(plugin_config, indent=2, default=str)}") + logger.debug(f"Raw form data keys: {list(form_data.keys())}") return error_response( ErrorCode.CONFIG_VALIDATION_FAILED, 'Configuration validation failed', @@ -4730,8 +4755,8 @@ def separate_secrets(config, secrets_set, prefix=''): if isinstance(value, dict): # Recursively handle nested dicts nested_regular, nested_secrets = separate_secrets(value, secrets_set, full_path) - if nested_regular: - regular[key] = nested_regular + # Always preserve the key in regular even if empty (maintains structure) + regular[key] = nested_regular if nested_secrets: secrets[key] = nested_secrets elif full_path in secrets_set: @@ -4743,6 +4768,24 @@ def separate_secrets(config, secrets_set, prefix=''): regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) + # Drop empty-string values from secrets_config. + # When get_plugin_config masks an x-secret field it returns '' so the + # browser never sees the real value. If the user re-submits the form + # without entering a new value, '' comes back here — we must not + # overwrite the existing stored secret with an empty string. + def _drop_empty_secrets(d): + """Recursively remove empty-string/whitespace/None entries from a secrets dict.""" + result = {} + for k, v in d.items(): + if isinstance(v, dict): + nested = _drop_empty_secrets(v) + if nested: + result[k] = nested + elif v is not None and not (isinstance(v, str) and v.strip() == ''): + result[k] = v + return result + secrets_config = _drop_empty_secrets(secrets_config) + # Get current configs current_config = api_v3.config_manager.load_config() current_secrets = api_v3.config_manager.get_raw_file_content('secrets') @@ -4751,17 +4794,21 @@ def separate_secrets(config, secrets_set, prefix=''): if plugin_id not in current_config: current_config[plugin_id] = {} - # Debug logging for live_priority before merge - if plugin_id == 'football-scoreboard': - print(f"[DEBUG] Before merge - current NFL live_priority: {current_config[plugin_id].get('nfl', {}).get('live_priority')}") - print(f"[DEBUG] Before merge - regular_config NFL live_priority: {regular_config.get('nfl', {}).get('live_priority')}") - current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) - # Debug logging for live_priority after merge - if plugin_id == 'football-scoreboard': - print(f"[DEBUG] After merge - NFL live_priority: {current_config[plugin_id].get('nfl', {}).get('live_priority')}") - print(f"[DEBUG] After merge - NCAA FB live_priority: {current_config[plugin_id].get('ncaa_fb', {}).get('live_priority')}") + # Filter out empty-string secret values so that the masked empty strings + # returned by the GET endpoint don't overwrite existing saved secrets. + def _filter_empty_secrets(d): + filtered = {} + for k, v in d.items(): + if isinstance(v, dict): + nested = _filter_empty_secrets(v) + if nested: + filtered[k] = nested + elif v is not None and not (isinstance(v, str) and v.strip() == ''): + filtered[k] = v + return filtered + secrets_config = _filter_empty_secrets(secrets_config) # Deep merge plugin secrets in secrets config if secrets_config: @@ -4977,8 +5024,8 @@ def separate_secrets(config, secrets_set, prefix=''): full_path = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): nested_regular, nested_secrets = separate_secrets(value, secrets_set, full_path) - if nested_regular: - regular[key] = nested_regular + # Always preserve the key in regular even if empty (maintains structure) + regular[key] = nested_regular if nested_secrets: secrets[key] = nested_secrets elif full_path in secrets_set: @@ -5007,8 +5054,14 @@ def separate_secrets(config, secrets_set, prefix=''): # Replace all secrets with defaults current_secrets[plugin_id] = default_secrets - # Save updated configs - api_v3.config_manager.save_config(current_config) + # Save updated configs (atomic save to prevent corruption) + success, error_msg = _save_config_atomic(api_v3.config_manager, current_config, create_backup=True) + if not success: + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to reset configuration: {error_msg}", + status_code=500 + ) if default_secrets or not preserve_secrets: api_v3.config_manager.save_raw_file_content('secrets', current_secrets) @@ -5044,8 +5097,6 @@ def execute_plugin_action(): try: data = request.get_json(force=True) or {} except Exception as e: - import logging - logger = logging.getLogger(__name__) logger.error(f"Error parsing JSON in execute_plugin_action: {e}") return jsonify({ 'status': 'error', @@ -5105,7 +5156,13 @@ def execute_plugin_action(): if not script_path: return jsonify({'status': 'error', 'message': 'Script path not defined for action'}), 400 - script_file = Path(plugin_dir) / script_path + script_file = (Path(plugin_dir) / script_path).resolve() + plugin_dir_resolved = Path(plugin_dir).resolve() + try: + script_file.relative_to(plugin_dir_resolved) + except ValueError: + return jsonify({'status': 'error', 'message': 'Invalid script path'}), 400 + if not script_file.exists(): return jsonify({'status': 'error', 'message': f'Script not found: {script_path}'}), 404 @@ -5147,7 +5204,7 @@ def execute_plugin_action(): try: result = subprocess.run( - ['python3', wrapper_path], + [sys.executable, wrapper_path], capture_output=True, text=True, timeout=120, @@ -5174,47 +5231,23 @@ def execute_plugin_action(): else: # Regular script execution - pass params via stdin if provided if action_params: - # Pass params as JSON via stdin - import tempfile + # Pass params as JSON directly via stdin (no wrapper script needed) import json as json_lib - - params_json = json_lib.dumps(action_params) - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: - wrapper.write(f'''import sys -import subprocess -import os -import json - -# Set LEDMATRIX_ROOT -os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" - -# Run the script and provide params as JSON via stdin -proc = subprocess.Popen( - [sys.executable, r"{script_file}"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=os.environ -) - -# Send params as JSON to stdin -params = {params_json} -stdout, _ = proc.communicate(input=json.dumps(params), timeout=120) -print(stdout) -sys.exit(proc.returncode) -''') - wrapper_path = wrapper.name + params_stdin = json_lib.dumps(action_params) try: result = subprocess.run( - ['python3', wrapper_path], + [sys.executable, str(script_file)], + input=params_stdin, capture_output=True, text=True, timeout=120, env=env ) - os.unlink(wrapper_path) + + # Log action results for debugging + if result.returncode != 0: + logger.warning(f"Plugin action {action_id} for {plugin_id} failed (exit {result.returncode}): stdout={result.stdout[:500]}, stderr={result.stderr[:500]}") # Try to parse output as JSON try: @@ -5236,14 +5269,13 @@ def execute_plugin_action(): 'output': result.stdout }) else: + logger.error(f"Plugin action {action_id} for {plugin_id} returned non-JSON error: stdout={result.stdout[:500]}, stderr={result.stderr[:500]}") return jsonify({ 'status': 'error', 'message': action_def.get('error_message', 'Action failed'), 'output': result.stdout + result.stderr }), 400 except subprocess.TimeoutExpired: - if os.path.exists(wrapper_path): - os.unlink(wrapper_path) return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 else: # No params - check for OAuth flow first, then run script normally @@ -5304,7 +5336,7 @@ def execute_plugin_action(): else: # Simple script execution result = subprocess.run( - ['python3', str(script_file)], + [sys.executable, str(script_file)], capture_output=True, text=True, timeout=60, @@ -5415,7 +5447,7 @@ def authenticate_spotify(): try: result = subprocess.run( - ['python3', wrapper_path], + [sys.executable, wrapper_path], capture_output=True, text=True, timeout=120, @@ -5522,7 +5554,7 @@ def authenticate_ytm(): # Run the authentication script result = subprocess.run( - ['python3', str(auth_script)], + [sys.executable, str(auth_script)], capture_output=True, text=True, timeout=60, diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index c9783ddbf..2a8e90366 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -393,6 +393,39 @@ def _load_plugin_config_partial(plugin_id): schema = json.load(f) except Exception as e: print(f"Warning: Could not load schema for {plugin_id}: {e}") + + # Mask secret fields (x-secret: true) before passing config to the template. + # load_config() deep-merges secrets into the main config, so config may contain + # plain-text API keys. Replace them with '' so the form never renders them. + if schema and 'properties' in schema: + try: + def _find_secret_fields(properties, prefix=''): + fields = set() + for field_name, field_props in properties.items(): + full_path = f"{prefix}.{field_name}" if prefix else field_name + if isinstance(field_props, dict) and field_props.get('x-secret', False): + fields.add(full_path) + if isinstance(field_props, dict) and field_props.get('type') == 'object' and 'properties' in field_props: + fields.update(_find_secret_fields(field_props['properties'], full_path)) + return fields + + def _mask_secrets(config_dict, secrets_set, prefix=''): + masked = {} + for key, value in config_dict.items(): + full_path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + masked[key] = _mask_secrets(value, secrets_set, full_path) + elif full_path in secrets_set: + masked[key] = '' + else: + masked[key] = value + return masked + + secret_fields = _find_secret_fields(schema['properties']) + if secret_fields: + config = _mask_secrets(config, secret_fields) + except Exception: + raise # Fail closed — do not silently leak secrets # Get web UI actions from plugin manifest web_ui_actions = [] diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 64607d6fd..4c8af8f22 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -11,8 +11,61 @@ {% set description = prop.description if prop.description else '' %} {% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %} + {# Handle custom-html widget (e.g., file managers loaded from plugin directory) #} + {% set custom_widget = prop.get('x-widget') or prop.get('x_widget') %} + {% if custom_widget == 'custom-html' %} + {% set html_file = prop.get('x-html-file') or prop.get('x_html_file') %} + {% if html_file and plugin_id %} +
+ + {% if description %}

{{ description }}

{% endif %} +
+
+ +

Loading...

+
+
+
+ + {% endif %} + {# Handle nested objects - check for widget first #} - {% if field_type == 'object' %} + {% elif field_type == 'object' %} {% set obj_widget = prop.get('x-widget') or prop.get('x_widget') %} {% if obj_widget == 'schedule-picker' %} {# Schedule picker widget - renders enable/mode/times UI #} @@ -850,15 +903,16 @@

Configuration

- {# Web UI Actions (if any) #} + {# Web UI Actions (if any) - only show actions that have a title (internal/API-only actions are hidden) #} {% if web_ui_actions %} -
+ + {% endif %} {% endfor %}
+ {% endif %} - + {# Action Buttons #}