From 5e7b6b81056cc00ae7deca4f2924688c8b6737c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 18:28:54 +0000 Subject: [PATCH 01/14] docs: expand CLAUDE.md with comprehensive codebase reference Rewrites the AI assistant reference to cover all major subsystems discovered in the current codebase: - Full directory tree with purpose annotations - Plugin system lifecycle, BasePlugin contract, and file structure - Vegas scroll mode and VegasDisplayMode enum - Web interface API endpoints and response format - Configuration management (main config, secrets, hot-reload) - Logging conventions using get_logger() - Testing patterns with PluginTestCase and available mocks - Coding standards, naming conventions, and common pitfalls - Development workflow for new plugins and emulator setup https://claude.ai/code/session_01Fd5xaf9kpQMw3Pk5R9dUdt --- CLAUDE.md | 552 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 529 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 61b775005..c576a695f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,31 +1,537 @@ -# 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 + +``` +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: +- `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 +``` +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 +``` +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 +``` + +--- + +## 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` | From d86fb89012b94b374278886aca039821ab62d8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 20:12:26 +0000 Subject: [PATCH 02/14] fix: three bugs in weather and stocks plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Weather plugin — API data never fetched after config saved via web UI WeatherPlugin did not override on_config_change(). The base class only updates self.config and self.enabled, so instance attributes set in __init__ (self.api_key, self.location, self.units, self.update_interval, self.show_current/hourly/daily, self.modes) were never refreshed. On every subsequent update() call the stale self.api_key still equalled the sentinel 'YOUR_OPENWEATHERMAP_API_KEY', causing an early return with a warning and no API request ever being made. Fix: add on_config_change() that re-reads all config-derived attributes, rebuilds self.modes, resets the update timer and error backoff so the new API key / location is used on the next update cycle, and clears the layout cache. ## Weather plugin — API key returned in plaintext to the browser GET /api/v3/plugins/config calls config_manager.load_config(), which deep-merges config_secrets.json into the main config before returning. The plugin config dict therefore contains the live API key value and that value was returned to the browser unchanged. The x-secret:true annotation in config_schema.json was only honoured on the POST (save) path, not on the GET path. Fix: after loading plugin_config in get_plugin_config(), load the plugin schema and recursively blank out any field marked x-secret:true before the response is serialised (replace value with ""). Also fix the matching save path: add _drop_empty_secrets() in save_plugin_config() so that if the user re-submits a form where an x-secret field is blank (because it was masked on GET), the empty string does not overwrite the stored secret. ## Stocks plugin — chart toggle has no effect StockDisplayRenderer.__init__() caches self.toggle_chart = config.get('display', {}).get('toggle_chart', True) at construction time. StockTickerPlugin had no on_config_change() override, so config changes from the web UI only updated the base class self.config dict. Neither config_manager.toggle_chart nor display_renderer.toggle_chart was ever refreshed. Even reload_config() only updated display_renderer.config (the dict reference) but not the cached boolean attribute, so the chart always rendered as if toggle_chart=True. Fix: - Add on_config_change() to StockTickerPlugin that updates config_manager.plugin_config, re-runs config_manager._load_config() to reparse all attributes, syncs display_renderer.toggle_chart from the freshly parsed value, updates all other component refs, and clears the scroll cache so the next display() re-renders with the new layout. - Fix reload_config() to also sync display_renderer.toggle_chart after calling config_manager.reload_config(). https://claude.ai/code/session_01Fd5xaf9kpQMw3Pk5R9dUdt --- plugins/stocks/display_renderer.py | 537 +++++++++++++++ plugins/stocks/manager.py | 420 ++++++++++++ plugins/weather/manager.py | 1002 ++++++++++++++++++++++++++++ web_interface/blueprints/api_v3.py | 42 ++ 4 files changed, 2001 insertions(+) create mode 100644 plugins/stocks/display_renderer.py create mode 100644 plugins/stocks/manager.py create mode 100644 plugins/weather/manager.py diff --git a/plugins/stocks/display_renderer.py b/plugins/stocks/display_renderer.py new file mode 100644 index 000000000..0b2667c79 --- /dev/null +++ b/plugins/stocks/display_renderer.py @@ -0,0 +1,537 @@ +""" +Display Renderer for Stock Ticker Plugin + +Handles all display creation, layout, and rendering logic for both +scrolling and static display modes. +""" + +import os +from typing import Dict, Any, List, Optional, Tuple +from PIL import Image, ImageDraw, ImageFont + +# Import common utilities +from src.common import ScrollHelper, LogoHelper, TextHelper + + +class StockDisplayRenderer: + """Handles rendering of stock and cryptocurrency displays.""" + + def __init__(self, config: Dict[str, Any], display_width: int, display_height: int, logger): + """Initialize the display renderer.""" + self.config = config + self.display_width = display_width + self.display_height = display_height + self.logger = logger + + # Display configuration + self.toggle_chart = config.get('display', {}).get('toggle_chart', True) + + # Load colors from customization structure (organized by element: symbol, price, price_delta) + # Support both new format (customization.stocks.*) and old format (top-level) for backwards compatibility + customization = config.get('customization', {}) + stocks_custom = customization.get('stocks', {}) + crypto_custom = customization.get('crypto', {}) + + # Stock colors - new format: customization.stocks.symbol/price/price_delta + # Old format fallback: top-level text_color, positive_color, negative_color + # Ensure all color values are integers (RGB values from config might be floats) + if stocks_custom.get('symbol') and 'text_color' in stocks_custom['symbol']: + # New format: separate colors for symbol and price + symbol_color_list = stocks_custom['symbol'].get('text_color', [255, 255, 255]) + price_color_list = stocks_custom.get('price', {}).get('text_color', [255, 255, 255]) + self.symbol_text_color = tuple(int(c) for c in symbol_color_list) + self.price_text_color = tuple(int(c) for c in price_color_list) + else: + # Old format: shared text_color for symbol and price + old_text_color_list = config.get('text_color', [255, 255, 255]) + old_text_color = tuple(int(c) for c in old_text_color_list) + self.symbol_text_color = old_text_color + self.price_text_color = old_text_color + + price_delta_custom = stocks_custom.get('price_delta', {}) + if price_delta_custom: + positive_color_list = price_delta_custom.get('positive_color', [0, 255, 0]) + negative_color_list = price_delta_custom.get('negative_color', [255, 0, 0]) + self.positive_color = tuple(int(c) for c in positive_color_list) + self.negative_color = tuple(int(c) for c in negative_color_list) + else: + # Old format fallback + positive_color_list = config.get('positive_color', [0, 255, 0]) + negative_color_list = config.get('negative_color', [255, 0, 0]) + self.positive_color = tuple(int(c) for c in positive_color_list) + self.negative_color = tuple(int(c) for c in negative_color_list) + + # Crypto colors - new format: customization.crypto.symbol/price/price_delta + # Old format fallback: customization.crypto.text_color, etc. + if crypto_custom.get('symbol') and 'text_color' in crypto_custom['symbol']: + # New format: separate colors for symbol and price + crypto_symbol_color_list = crypto_custom['symbol'].get('text_color', [255, 215, 0]) + crypto_price_color_list = crypto_custom.get('price', {}).get('text_color', [255, 215, 0]) + self.crypto_symbol_text_color = tuple(int(c) for c in crypto_symbol_color_list) + self.crypto_price_text_color = tuple(int(c) for c in crypto_price_color_list) + else: + # Old format: shared text_color for symbol and price + old_crypto_text_color_list = crypto_custom.get('text_color', [255, 215, 0]) + old_crypto_text_color = tuple(int(c) for c in old_crypto_text_color_list) + self.crypto_symbol_text_color = old_crypto_text_color + self.crypto_price_text_color = old_crypto_text_color + + crypto_price_delta_custom = crypto_custom.get('price_delta', {}) + if crypto_price_delta_custom: + crypto_positive_color_list = crypto_price_delta_custom.get('positive_color', [0, 255, 0]) + crypto_negative_color_list = crypto_price_delta_custom.get('negative_color', [255, 0, 0]) + self.crypto_positive_color = tuple(int(c) for c in crypto_positive_color_list) + self.crypto_negative_color = tuple(int(c) for c in crypto_negative_color_list) + else: + # Old format fallback + crypto_positive_color_list = crypto_custom.get('positive_color', [0, 255, 0]) + crypto_negative_color_list = crypto_custom.get('negative_color', [255, 0, 0]) + self.crypto_positive_color = tuple(int(c) for c in crypto_positive_color_list) + self.crypto_negative_color = tuple(int(c) for c in crypto_negative_color_list) + + # Initialize helpers + self.logo_helper = LogoHelper(display_width, display_height, logger=logger) + self.text_helper = TextHelper(logger=self.logger) + + # Initialize scroll helper + self.scroll_helper = ScrollHelper(display_width, display_height, logger) + + # Load custom fonts from config + # Fonts are under customization.stocks/crypto.symbol/price/price_delta + # For backwards compatibility, try to load from customization.fonts first + fonts_config = customization.get('fonts', {}) + if fonts_config: + # Old format: fonts at customization.fonts level (shared for stocks and crypto) + self.symbol_font = self._load_custom_font_from_element_config(fonts_config.get('symbol', {})) + self.price_font = self._load_custom_font_from_element_config(fonts_config.get('price', {})) + self.price_delta_font = self._load_custom_font_from_element_config(fonts_config.get('price_delta', {})) + else: + # New format: fonts at customization.stocks/crypto.symbol/price/price_delta + # Use stocks font config (crypto can override later if needed, but currently shares fonts) + stocks_custom = customization.get('stocks', {}) + self.symbol_font = self._load_custom_font_from_element_config(stocks_custom.get('symbol', {})) + self.price_font = self._load_custom_font_from_element_config(stocks_custom.get('price', {})) + self.price_delta_font = self._load_custom_font_from_element_config(stocks_custom.get('price_delta', {})) + + def _load_custom_font_from_element_config(self, element_config: Dict[str, Any]) -> ImageFont.FreeTypeFont: + """ + Load a custom font from an element configuration dictionary. + + Args: + element_config: Configuration dict for a single element (symbol, price, or price_delta) + containing 'font' and 'font_size' keys + + Returns: + PIL ImageFont object + """ + # Get font name and size, with defaults + font_name = element_config.get('font', 'PressStart2P-Regular.ttf') + font_size = int(element_config.get('font_size', 8)) # Ensure integer for PIL + + # Build font path + font_path = os.path.join('assets', 'fonts', font_name) + + # Try to load the font + try: + if os.path.exists(font_path): + # Try loading as TTF first (works for both TTF and some BDF files with PIL) + if font_path.lower().endswith('.ttf'): + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded font: {font_name} at size {font_size}") + return font + elif font_path.lower().endswith('.bdf'): + # PIL's ImageFont.truetype() can sometimes handle BDF files + # If it fails, we'll fall through to the default font + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + return font + except Exception: + self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") + # Fall through to default + else: + self.logger.warning(f"Unknown font file type: {font_name}, using default") + else: + self.logger.warning(f"Font file not found: {font_path}, using default") + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}, using default") + + # Fall back to default font + default_font_path = os.path.join('assets', 'fonts', 'PressStart2P-Regular.ttf') + try: + if os.path.exists(default_font_path): + return ImageFont.truetype(default_font_path, font_size) + else: + self.logger.warning("Default font not found, using PIL default") + return ImageFont.load_default() + except Exception as e: + self.logger.error(f"Error loading default font: {e}") + return ImageFont.load_default() + + def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image: + """Create a display image for a single stock or crypto - matching old stock manager layout exactly.""" + # Create a wider image for scrolling - adjust width based on chart toggle + # Match old stock_manager: width = int(self.display_manager.matrix.width * (2 if self.toggle_chart else 1.5)) + # Ensure dimensions are integers + width = int(self.display_width * (2 if self.toggle_chart else 1.5)) + height = int(self.display_height) + image = Image.new('RGB', (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(image) + + is_crypto = data.get('is_crypto', False) + + # Draw large stock/crypto logo on the left + logo = self._get_stock_logo(symbol, is_crypto) + if logo: + # Position logo on the left side with minimal spacing - matching old stock_manager + # Ensure positions are integers + logo_x = 2 # Small margin from left edge + logo_y = int((height - logo.height) // 2) + image.paste(logo, (int(logo_x), int(logo_y)), logo) + + # Use custom fonts loaded from config + symbol_font = self.symbol_font + price_font = self.price_font + change_font = self.price_delta_font + + # Create text elements + display_symbol = symbol.replace('-USD', '') if is_crypto else symbol + symbol_text = display_symbol + price_text = f"${data['price']:.2f}" + + # Build change text based on show_change and show_percentage flags + # Get flags from config (stock-specific or crypto-specific) + if is_crypto: + show_change = self.config.get('crypto', {}).get('show_change', True) + show_percentage = self.config.get('crypto', {}).get('show_percentage', True) + else: + show_change = self.config.get('show_change', True) + show_percentage = self.config.get('show_percentage', True) + + # Build change text components + change_parts = [] + if show_change: + change_parts.append(f"{data['change']:+.2f}") + if show_percentage: + # Use change_percent if available, otherwise calculate from change and open + if 'change_percent' in data: + change_parts.append(f"({data['change_percent']:+.1f}%)") + elif 'open' in data and data['open'] > 0: + change_percent = (data['change'] / data['open']) * 100 + change_parts.append(f"({change_percent:+.1f}%)") + + change_text = " ".join(change_parts) if change_parts else "" + + # Get colors based on change + if data['change'] >= 0: + change_color = self.positive_color if not is_crypto else self.crypto_positive_color + else: + change_color = self.negative_color if not is_crypto else self.crypto_negative_color + + # Use symbol color for symbol, price color for price + symbol_color = self.symbol_text_color if not is_crypto else self.crypto_symbol_text_color + price_color = self.price_text_color if not is_crypto else self.crypto_price_text_color + + # Calculate text dimensions for proper spacing (matching old stock manager) + symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font) + price_bbox = draw.textbbox((0, 0), price_text, font=price_font) + + # Only calculate change_bbox if change_text is not empty + if change_text: + change_bbox = draw.textbbox((0, 0), change_text, font=change_font) + change_height = int(change_bbox[3] - change_bbox[1]) + else: + change_bbox = (0, 0, 0, 0) + change_height = 0 + + # Calculate total height needed - adjust gaps based on chart toggle + # Match old stock_manager: text_gap = 2 if self.toggle_chart else 1 + text_gap = 2 if self.toggle_chart else 1 + # Only add change height and gap if change is shown + change_gap = text_gap if change_text else 0 + symbol_height = int(symbol_bbox[3] - symbol_bbox[1]) + price_height = int(price_bbox[3] - price_bbox[1]) + total_text_height = symbol_height + price_height + change_height + (text_gap + change_gap) # Account for gaps between elements + + # Calculate starting y position to center all text + start_y = int((height - total_text_height) // 2) + + # Calculate center x position for the column - adjust based on chart toggle + # Match old stock_manager exactly + if self.toggle_chart: + # When chart is enabled, center text more to the left + column_x = int(width / 2.85) + else: + # When chart is disabled, position text with more space from logo + column_x = int(width / 2.2) + + # Draw symbol + symbol_width = int(symbol_bbox[2] - symbol_bbox[0]) + symbol_x = int(column_x - (symbol_width / 2)) + draw.text((symbol_x, start_y), symbol_text, font=symbol_font, fill=symbol_color) + + # Draw price + price_width = int(price_bbox[2] - price_bbox[0]) + price_x = int(column_x - (price_width / 2)) + symbol_height = int(symbol_bbox[3] - symbol_bbox[1]) + price_y = int(start_y + symbol_height + text_gap) # Adjusted gap + draw.text((price_x, price_y), price_text, font=price_font, fill=price_color) + + # Draw change with color based on value (only if change_text is not empty) + if change_text: + change_width = int(change_bbox[2] - change_bbox[0]) + change_x = int(column_x - (change_width / 2)) + price_height = int(price_bbox[3] - price_bbox[1]) + change_y = int(price_y + price_height + text_gap) # Adjusted gap + draw.text((change_x, change_y), change_text, font=change_font, fill=change_color) + + # Draw mini chart on the right only if toggle_chart is enabled + if self.toggle_chart and 'price_history' in data and len(data['price_history']) >= 2: + self._draw_mini_chart(draw, data['price_history'], width, height, change_color) + + return image + + def create_static_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image: + """Create a static display for one stock/crypto (no scrolling).""" + # Ensure dimensions are integers + image = Image.new('RGB', (int(self.display_width), int(self.display_height)), (0, 0, 0)) + draw = ImageDraw.Draw(image) + + is_crypto = data.get('is_crypto', False) + + # Draw logo + logo = self._get_stock_logo(symbol, is_crypto) + if logo: + # Ensure positions are integers + logo_x = 5 + logo_y = int((int(self.display_height) - logo.height) // 2) + image.paste(logo, (int(logo_x), int(logo_y)), logo) + + # Use custom fonts loaded from config + symbol_font = self.symbol_font + price_font = self.price_font + change_font = self.price_delta_font + + # Create text + display_symbol = symbol.replace('-USD', '') if is_crypto else symbol + symbol_text = display_symbol + price_text = f"${data['price']:.2f}" + + # Build change text based on show_change and show_percentage flags + if is_crypto: + show_change = self.config.get('crypto', {}).get('show_change', True) + show_percentage = self.config.get('crypto', {}).get('show_percentage', True) + else: + show_change = self.config.get('show_change', True) + show_percentage = self.config.get('show_percentage', True) + + # Build change text components + change_parts = [] + if show_change: + change_parts.append(f"{data['change']:+.2f}") + if show_percentage: + if 'change_percent' in data: + change_parts.append(f"({data['change_percent']:+.1f}%)") + elif 'open' in data and data['open'] > 0: + change_percent = (data['change'] / data['open']) * 100 + change_parts.append(f"({change_percent:+.1f}%)") + + change_text = " ".join(change_parts) if change_parts else "" + + # Get colors + if data['change'] >= 0: + change_color = self.positive_color if not is_crypto else self.crypto_positive_color + else: + change_color = self.negative_color if not is_crypto else self.crypto_negative_color + + # Use symbol color for symbol, price color for price + symbol_color = self.symbol_text_color if not is_crypto else self.crypto_symbol_text_color + price_color = self.price_text_color if not is_crypto else self.crypto_price_text_color + + # Calculate positions + symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font) + price_bbox = draw.textbbox((0, 0), price_text, font=price_font) + + # Only calculate change_bbox if change_text is not empty + if change_text: + change_bbox = draw.textbbox((0, 0), change_text, font=change_font) + else: + change_bbox = (0, 0, 0, 0) + + # Center everything - ensure integer + center_x = int(self.display_width) // 2 + + # Draw symbol + symbol_width = int(symbol_bbox[2] - symbol_bbox[0]) + symbol_x = int(center_x - (symbol_width / 2)) + draw.text((symbol_x, 5), symbol_text, font=symbol_font, fill=symbol_color) + + # Draw price + price_width = int(price_bbox[2] - price_bbox[0]) + price_x = int(center_x - (price_width / 2)) + draw.text((price_x, 15), price_text, font=price_font, fill=price_color) + + # Draw change (only if change_text is not empty) + if change_text: + change_width = int(change_bbox[2] - change_bbox[0]) + change_x = int(center_x - (change_width / 2)) + draw.text((change_x, 25), change_text, font=change_font, fill=change_color) + + return image + + def create_scrolling_display(self, all_data: Dict[str, Any]) -> Image.Image: + """Create a wide scrolling image with all stocks/crypto - matching old stock_manager spacing.""" + if not all_data: + return self._create_error_display() + + # Calculate total width needed - match old stock_manager spacing logic + # Ensure dimensions are integers + width = int(self.display_width) + height = int(self.display_height) + + # Create individual stock displays + stock_displays = [] + for symbol, data in all_data.items(): + display = self.create_stock_display(symbol, data) + stock_displays.append(display) + + # Calculate spacing - match old stock_manager exactly + # Old code: stock_gap = width // 6, element_gap = width // 8 + stock_gap = int(width // 6) # Reduced gap between stocks + element_gap = int(width // 8) # Reduced gap between elements within a stock + + # Calculate total width - match old stock_manager calculation + # Old code: total_width = sum(width * 2 for _ in symbols) + stock_gap * (len(symbols) - 1) + element_gap * (len(symbols) * 2 - 1) + # But each display already has its own width (width * 2 or width * 1.5), so we sum display widths + # Ensure all values are integers + total_width = sum(int(display.width) for display in stock_displays) + total_width += int(stock_gap) * (len(stock_displays) - 1) + total_width += int(element_gap) * (len(stock_displays) * 2 - 1) + + # Create scrolling image - ensure dimensions are integers + scrolling_image = Image.new('RGB', (int(total_width), int(height)), (0, 0, 0)) + + # Paste all stock displays with spacing - match old stock_manager logic + # Old code: current_x = width (starts with display width gap) + current_x = int(width) # Add initial gap before the first stock + + for i, display in enumerate(stock_displays): + # Paste this stock image into the full image - ensure position is integer tuple + scrolling_image.paste(display, (int(current_x), 0)) + + # Move to next position with consistent spacing + # Old code: current_x += width * 2 + element_gap + current_x += int(display.width) + int(element_gap) + + # Add extra gap between stocks (except after the last stock) + if i < len(stock_displays) - 1: + current_x += int(stock_gap) + + return scrolling_image + + def _get_stock_logo(self, symbol: str, is_crypto: bool = False) -> Optional[Image.Image]: + """Get stock or crypto logo image - matching old stock manager sizing.""" + try: + if is_crypto: + # Try crypto icons first + logo_path = f"assets/stocks/crypto_icons/{symbol}.png" + else: + # Try stock icons + logo_path = f"assets/stocks/ticker_icons/{symbol}.png" + + # Use same sizing as old stock manager (display_width/1.2, display_height/1.2) + max_size = min(int(self.display_width / 1.2), int(self.display_height / 1.2)) + return self.logo_helper.load_logo(symbol, logo_path, max_size, max_size) + + except (OSError, IOError) as e: + self.logger.warning("Error loading logo for %s: %s", symbol, e) + return None + + def _get_stock_color(self, change: float) -> Tuple[int, int, int]: + """Get color based on stock performance - matching old stock manager.""" + if change > 0: + return (0, 255, 0) # Green for positive + elif change < 0: + return (255, 0, 0) # Red for negative + return (255, 255, 0) # Yellow for no change + + def _draw_mini_chart(self, draw: ImageDraw.Draw, price_history: List[Dict], + width: int, height: int, color: Tuple[int, int, int]) -> None: + """Draw a mini price chart on the right side of the display - matching old stock_manager exactly.""" + if len(price_history) < 2: + return + + # Chart dimensions - match old stock_manager exactly + # Old code: chart_width = int(width // 2.5), chart_height = height // 1.5 + # Ensure all dimensions are integers + chart_width = int(width / 2.5) # Reduced from width//2.5 to prevent overlap + chart_height = int(height / 1.5) + chart_x = int(width - chart_width - 4) # 4px margin from right edge + chart_y = int((height - chart_height) / 2) + + # Extract prices - match old stock_manager exactly + prices = [point['price'] for point in price_history if 'price' in point] + if len(prices) < 2: + return + + # Find min and max prices for scaling - match old stock_manager + min_price = min(prices) + max_price = max(prices) + + # Add padding to avoid flat lines when prices are very close - match old stock_manager + price_range = max_price - min_price + if price_range < 0.01: + min_price -= 0.01 + max_price += 0.01 + price_range = 0.02 + + if price_range == 0: + # All prices are the same, draw a horizontal line + y = int(chart_y + chart_height / 2) + draw.line([(chart_x, y), (chart_x + chart_width, y)], fill=color, width=1) + return + + # Calculate points for the line - match old stock_manager exactly + # Ensure all coordinates are integers + points = [] + for i, price in enumerate(prices): + x = int(chart_x + (i * chart_width) / (len(prices) - 1)) + y = int(chart_y + chart_height - int(((price - min_price) / price_range) * chart_height)) + points.append((x, y)) + + # Draw lines between points - match old stock_manager + if len(points) > 1: + for i in range(len(points) - 1): + draw.line([points[i], points[i + 1]], fill=color, width=1) + + def _create_error_display(self) -> Image.Image: + """Create an error display when no data is available.""" + # Ensure dimensions are integers + image = Image.new('RGB', (int(self.display_width), int(self.display_height)), (0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Use symbol font for error display + error_font = self.symbol_font + + # Draw error message + error_text = "No Data Available" + bbox = draw.textbbox((0, 0), error_text, font=error_font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Ensure dimensions are integers + x = (int(self.display_width) - text_width) // 2 + y = (int(self.display_height) - text_height) // 2 + + draw.text((x, y), error_text, font=error_font, fill=(255, 0, 0)) + + return image + + def set_toggle_chart(self, enabled: bool) -> None: + """Set whether to show mini charts.""" + self.toggle_chart = enabled + self.logger.debug("Chart toggle set to: %s", enabled) + + def get_scroll_helper(self) -> ScrollHelper: + """Get the scroll helper instance.""" + return self.scroll_helper diff --git a/plugins/stocks/manager.py b/plugins/stocks/manager.py new file mode 100644 index 000000000..a0300e2e6 --- /dev/null +++ b/plugins/stocks/manager.py @@ -0,0 +1,420 @@ +""" +Stock & Crypto Ticker Plugin for LEDMatrix (Refactored) + +Displays scrolling stock tickers with prices, changes, and optional charts +for stocks and cryptocurrencies. This refactored version splits functionality +into focused modules for better maintainability. +""" + +import time +from typing import Dict, Any, Optional + +from src.plugin_system.base_plugin import BasePlugin + +# Import our modular components +from data_fetcher import StockDataFetcher +from display_renderer import StockDisplayRenderer +from chart_renderer import StockChartRenderer +from config_manager import StockConfigManager + + +class StockTickerPlugin(BasePlugin): + """ + Stock and cryptocurrency ticker plugin with scrolling display. + + This refactored version uses modular components: + - StockDataFetcher: Handles API calls and data fetching + - StockDisplayRenderer: Handles display creation and layout + - StockChartRenderer: Handles chart drawing functionality + - StockConfigManager: Handles configuration management + """ + + def __init__(self, plugin_id: str, config: Dict[str, Any], + display_manager, cache_manager, plugin_manager): + """Initialize the stock ticker plugin.""" + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + + # Get display dimensions + self.display_width = display_manager.width + self.display_height = display_manager.height + + # Initialize modular components + self.config_manager = StockConfigManager(config, self.logger) + self.data_fetcher = StockDataFetcher(self.config_manager, self.cache_manager, self.logger) + self.display_renderer = StockDisplayRenderer( + self.config_manager.plugin_config, + self.display_width, + self.display_height, + self.logger + ) + self.chart_renderer = StockChartRenderer( + self.config_manager.plugin_config, + self.display_width, + self.display_height, + self.logger + ) + + # Plugin state + self.stock_data = {} + self.current_stock_index = 0 + self.scroll_complete = False + self._has_scrolled = False + + # Expose enable_scrolling for display controller FPS detection + self.enable_scrolling = self.config_manager.enable_scrolling + self.last_update_time = 0 + + # Initialize scroll helper + self.scroll_helper = self.display_renderer.get_scroll_helper() + # Convert pixels per frame to pixels per second for ScrollHelper + # scroll_speed is pixels per frame, scroll_delay is seconds per frame + # pixels per second = pixels per frame / seconds per frame + pixels_per_second = self.config_manager.scroll_speed / self.config_manager.scroll_delay if self.config_manager.scroll_delay > 0 else self.config_manager.scroll_speed * 100 + self.scroll_helper.set_scroll_speed(pixels_per_second) + self.scroll_helper.set_scroll_delay(self.config_manager.scroll_delay) + + # Configure dynamic duration settings + self.scroll_helper.set_dynamic_duration_settings( + enabled=self.config_manager.dynamic_duration, + min_duration=int(self.config_manager.min_duration), + max_duration=int(self.config_manager.max_duration), + buffer=self.config_manager.duration_buffer + ) + + self.logger.info("Stock ticker plugin initialized - %dx%d", + self.display_width, self.display_height) + + def update(self) -> None: + """Update stock and crypto data.""" + current_time = time.time() + + # Check if it's time to update + if current_time - self.last_update_time >= self.config_manager.update_interval: + try: + self.logger.debug("Updating stock and crypto data") + fetched_data = self.data_fetcher.fetch_all_data() + self.stock_data = fetched_data + self.last_update_time = current_time + + # Clear scroll cache when data updates + if hasattr(self.scroll_helper, 'cached_image'): + self.scroll_helper.cached_image = None + + + except Exception as e: + import traceback + self.logger.error("Error updating stock/crypto data: %s", e) + self.logger.debug("Traceback: %s", traceback.format_exc()) + + def display(self, force_clear: bool = False) -> None: + """Display stocks with scrolling or static mode.""" + if not self.stock_data: + self.logger.warning("No stock data available, showing error state") + self._show_error_state() + return + + if self.config_manager.enable_scrolling: + self._display_scrolling(force_clear) + else: + self._display_static(force_clear) + + def _display_scrolling(self, force_clear: bool = False) -> None: + """Display stocks with smooth scrolling animation.""" + # Create scrolling image if needed + if not self.scroll_helper.cached_image or force_clear: + self._create_scrolling_display() + + if force_clear: + self.scroll_helper.reset_scroll() + self._has_scrolled = False + self.scroll_complete = False + + # Signal scrolling state + self.display_manager.set_scrolling_state(True) + self.display_manager.process_deferred_updates() + + # Update scroll position using the scroll helper + self.scroll_helper.update_scroll_position() + + # Get visible portion + visible_portion = self.scroll_helper.get_visible_portion() + if visible_portion: + # Update display - paste overwrites previous content (no need to clear) + self.display_manager.image.paste(visible_portion, (0, 0)) + self.display_manager.update_display() + + # Log frame rate (less frequently to avoid spam) + self.scroll_helper.log_frame_rate() + + # Check if scroll is complete using ScrollHelper's method + if hasattr(self.scroll_helper, 'is_scroll_complete'): + self.scroll_complete = self.scroll_helper.is_scroll_complete() + elif self.scroll_helper.scroll_position == 0 and self._has_scrolled: + # Fallback: check if we've wrapped around (position is 0 after scrolling) + self.scroll_complete = True + + def _display_static(self, force_clear: bool = False) -> None: + """Display stocks in static mode - one at a time without scrolling.""" + # Signal not scrolling + self.display_manager.set_scrolling_state(False) + + # Get current stock + symbols = list(self.stock_data.keys()) + if not symbols: + self._show_error_state() + return + + current_symbol = symbols[self.current_stock_index % len(symbols)] + current_data = self.stock_data[current_symbol] + + # Create static display + static_image = self.display_renderer.create_static_display(current_symbol, current_data) + + # Update display - paste overwrites previous content (no need to clear) + self.display_manager.image.paste(static_image, (0, 0)) + self.display_manager.update_display() + + # Move to next stock after a delay + time.sleep(2) # Show each stock for 2 seconds + self.current_stock_index += 1 + + def _create_scrolling_display(self): + """Create the wide scrolling image with all stocks.""" + try: + # Create scrolling image using display renderer + scrolling_image = self.display_renderer.create_scrolling_display(self.stock_data) + + if scrolling_image: + # Set up scroll helper with the image (properly initializes cached_array and state) + self.scroll_helper.set_scrolling_image(scrolling_image) + + self.logger.debug("Created scrolling image: %dx%d", + scrolling_image.width, scrolling_image.height) + else: + self.logger.error("Failed to create scrolling image") + self.scroll_helper.clear_cache() + + except Exception as e: + import traceback + self.logger.error("Error creating scrolling display: %s", e) + self.logger.error("Traceback: %s", traceback.format_exc()) + self.scroll_helper.clear_cache() + + def _show_error_state(self): + """Show error state when no data is available.""" + try: + error_image = self.display_renderer._create_error_display() + self.display_manager.image.paste(error_image, (0, 0)) + self.display_manager.update_display() + except Exception as e: + self.logger.error("Error showing error state: %s", e) + + def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: + """ + Calculate the expected cycle duration based on content width and scroll speed. + + This implements dynamic duration scaling where: + - Duration is calculated from total scroll distance and scroll speed + - Includes buffer time for smooth cycling + - Respects min/max duration limits + + Args: + display_mode: The display mode (unused for stock ticker as it has a single mode) + + Returns: + Calculated duration in seconds, or None if dynamic duration is disabled or not available + """ + # display_mode is unused but kept for API consistency with other plugins + _ = display_mode + if not self.config_manager.dynamic_duration: + return None + + # Check if we have a cached image with calculated duration + if self.scroll_helper and self.scroll_helper.cached_image: + try: + dynamic_duration = self.scroll_helper.get_dynamic_duration() + if dynamic_duration and dynamic_duration > 0: + self.logger.debug( + "get_cycle_duration() returning calculated duration: %.1fs", + dynamic_duration + ) + return float(dynamic_duration) + except Exception as e: + self.logger.warning( + "Error getting dynamic duration from scroll helper: %s", + e + ) + + # If no cached image yet, return None (will be calculated when image is created) + self.logger.debug("get_cycle_duration() returning None (no cached image yet)") + return None + + def get_display_duration(self) -> float: + """ + Get the display duration in seconds. + + If dynamic duration is enabled and scroll helper has calculated a duration, + use that. Otherwise use the static display_duration. + """ + # If dynamic duration is enabled and scroll helper has calculated a duration, use it + if (self.config_manager.dynamic_duration and + hasattr(self.scroll_helper, 'calculated_duration') and + self.scroll_helper.calculated_duration > 0): + return float(self.scroll_helper.calculated_duration) + + # Otherwise use static duration + return self.config_manager.get_display_duration() + + def get_dynamic_duration(self) -> int: + """Get the dynamic duration setting.""" + return self.config_manager.get_dynamic_duration() + + def supports_dynamic_duration(self) -> bool: + """ + Determine whether this plugin should use dynamic display durations. + + Returns True if dynamic_duration is enabled in the display config. + """ + return bool(self.config_manager.dynamic_duration) + + def get_dynamic_duration_cap(self) -> Optional[float]: + """ + Return the maximum duration (in seconds) the controller should wait for + this plugin to complete its display cycle when using dynamic duration. + + Returns the max_duration from config, or None if not set. + """ + if not self.config_manager.dynamic_duration: + return None + + max_duration = self.config_manager.max_duration + if max_duration and max_duration > 0: + return float(max_duration) + return None + + def is_cycle_complete(self) -> bool: + """ + Report whether the plugin has shown a full cycle of content. + + For scrolling content, this checks if the scroll has completed one full cycle. + """ + if not self.config_manager.dynamic_duration: + # If dynamic duration is disabled, always report complete + return True + + if not self.config_manager.enable_scrolling: + # For static mode, cycle is complete after showing all stocks once + if not self.stock_data: + return True + symbols = list(self.stock_data.keys()) + return self.current_stock_index >= len(symbols) + + # For scrolling mode, check if scroll has completed + if hasattr(self.scroll_helper, 'is_scroll_complete'): + return self.scroll_helper.is_scroll_complete() + + # Fallback: check if scroll position has wrapped around + return self.scroll_complete + + def reset_cycle_state(self) -> None: + """ + Reset any internal counters/state related to cycle tracking. + + Called by the display controller before beginning a new dynamic-duration + session. Resets scroll position and stock index. + """ + super().reset_cycle_state() + self.scroll_complete = False + self.current_stock_index = 0 + self._has_scrolled = False + if hasattr(self.scroll_helper, 'reset_scroll'): + self.scroll_helper.reset_scroll() + + def get_info(self) -> Dict[str, Any]: + """Get plugin information.""" + return self.config_manager.get_plugin_info() + + # Configuration methods + def set_toggle_chart(self, enabled: bool) -> None: + """Set whether to show mini charts.""" + self.config_manager.set_toggle_chart(enabled) + self.display_renderer.set_toggle_chart(enabled) + + def set_scroll_speed(self, speed: float) -> None: + """Set the scroll speed (pixels per frame).""" + self.config_manager.set_scroll_speed(speed) + # Convert pixels per frame to pixels per second for ScrollHelper + pixels_per_second = speed / self.config_manager.scroll_delay if self.config_manager.scroll_delay > 0 else speed * 100 + self.scroll_helper.set_scroll_speed(pixels_per_second) + + def set_scroll_delay(self, delay: float) -> None: + """Set the scroll delay.""" + self.config_manager.set_scroll_delay(delay) + # Update scroll helper with new delay and recalculate pixels per second + self.scroll_helper.set_scroll_delay(delay) + # Recalculate pixels per second with new delay + pixels_per_second = self.config_manager.scroll_speed / delay if delay > 0 else self.config_manager.scroll_speed * 100 + self.scroll_helper.set_scroll_speed(pixels_per_second) + + def set_enable_scrolling(self, enabled: bool) -> None: + """Set whether scrolling is enabled.""" + self.config_manager.set_enable_scrolling(enabled) + self.enable_scrolling = enabled # Keep in sync + + def validate_config(self) -> bool: + """Validate plugin configuration.""" + # Call parent validation first + if not super().validate_config(): + return False + + # Use config manager's validation + if not self.config_manager.validate_config(): + return False + + return True + + def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Reload all config-derived attributes when settings change via web UI.""" + super().on_config_change(new_config) + + # Feed new config into config_manager and re-parse all cached attributes + self.config_manager.plugin_config = new_config + self.config_manager._load_config() + + # Sync cached scalar attributes in display_renderer that are read at + # __init__ time and never automatically updated from the config dict. + self.display_renderer.config = new_config + self.display_renderer.toggle_chart = self.config_manager.toggle_chart + + # Sync remaining components + self.chart_renderer.config = new_config + self.data_fetcher.config = self.config_manager.plugin_config + + # Clear scroll cache so the next display() call re-renders with the + # new settings (e.g. chart on/off changes the image dimensions). + self.scroll_helper.clear_cache() + + self.logger.info( + "Stock ticker config reloaded: chart=%s, scroll_speed=%.1f", + self.config_manager.toggle_chart, + self.config_manager.scroll_speed, + ) + + def reload_config(self) -> None: + """Reload configuration.""" + self.config_manager.reload_config() + # Update components with new config + self.data_fetcher.config = self.config_manager.plugin_config + self.display_renderer.config = self.config_manager.plugin_config + # Sync the cached toggle_chart attribute — setting .config alone is not enough + self.display_renderer.toggle_chart = self.config_manager.toggle_chart + self.chart_renderer.config = self.config_manager.plugin_config + + def cleanup(self) -> None: + """Clean up resources.""" + try: + if hasattr(self.data_fetcher, 'cleanup'): + self.data_fetcher.cleanup() + self.logger.info("Stock ticker plugin cleanup completed") + except Exception as e: + self.logger.error("Error during cleanup: %s", e) diff --git a/plugins/weather/manager.py b/plugins/weather/manager.py new file mode 100644 index 000000000..92165942c --- /dev/null +++ b/plugins/weather/manager.py @@ -0,0 +1,1002 @@ +""" +Weather Plugin for LEDMatrix + +Comprehensive weather display with current conditions, hourly forecast, and daily forecast. +Uses OpenWeatherMap API to provide accurate weather information with beautiful icons. + +Features: +- Current weather conditions with temperature, humidity, wind speed +- Hourly forecast (next 24-48 hours) +- Daily forecast (next 7 days) +- Weather icons matching conditions +- UV index display +- Automatic error handling and retry logic + +API Version: 1.0.0 +""" + +import logging +import requests +import time +from datetime import datetime +from typing import Dict, Any, List, Optional +from PIL import Image, ImageDraw +from pathlib import Path + +from src.plugin_system.base_plugin import BasePlugin + +# Import weather icons from local module +try: + # Try relative import first (if module is loaded as package) + from .weather_icons import WeatherIcons +except ImportError: + try: + # Fallback to direct import (plugin dir is in sys.path) + import weather_icons + WeatherIcons = weather_icons.WeatherIcons + except ImportError: + # Fallback if weather icons not available + class WeatherIcons: + @staticmethod + def draw_weather_icon(image, icon_code, x, y, size): + # Simple fallback - just draw a circle + draw = ImageDraw.Draw(image) + draw.ellipse([x, y, x + size, y + size], outline=(255, 255, 255), width=2) + +# Import API counter function +try: + from web_interface_v2 import increment_api_counter +except ImportError: + def increment_api_counter(kind: str, count: int = 1): + pass + +logger = logging.getLogger(__name__) + + +class WeatherPlugin(BasePlugin): + """ + Weather plugin that displays current conditions and forecasts. + + Supports three display modes: + - weather: Current conditions + - hourly_forecast: Hourly forecast for next 48 hours + - daily_forecast: Daily forecast for next 7 days + + Configuration options: + api_key (str): OpenWeatherMap API key + location (dict): City, state, country for weather data + units (str): 'imperial' (F) or 'metric' (C) + update_interval (int): Seconds between API updates + display_modes (dict): Enable/disable specific display modes + """ + + def __init__(self, plugin_id: str, config: Dict[str, Any], + display_manager, cache_manager, plugin_manager): + """Initialize the weather plugin.""" + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + + # Weather configuration + self.api_key = config.get('api_key', 'YOUR_OPENWEATHERMAP_API_KEY') + + # Location - read from flat format (location_city, location_state, location_country) + # These are the fields defined in config_schema.json for the web interface + self.location = { + 'city': config.get('location_city', 'Dallas'), + 'state': config.get('location_state', 'Texas'), + 'country': config.get('location_country', 'US') + } + + self.units = config.get('units', 'imperial') + + # Handle update_interval - ensure it's an int + update_interval = config.get('update_interval', 1800) + try: + self.update_interval = int(update_interval) + except (ValueError, TypeError): + self.update_interval = 1800 + + # Display modes - read from flat boolean fields + # These are the fields defined in config_schema.json for the web interface + self.show_current = config.get('show_current_weather', True) + self.show_hourly = config.get('show_hourly_forecast', True) + self.show_daily = config.get('show_daily_forecast', True) + + # Data storage + self.weather_data = None + self.forecast_data = None + self.hourly_forecast = None + self.daily_forecast = None + self.last_update = 0 + + # Error handling and throttling + self.consecutive_errors = 0 + self.last_error_time = 0 + self.error_backoff_time = 60 + self.max_consecutive_errors = 5 + self.error_log_throttle = 300 # Only log errors every 5 minutes + self.last_error_log_time = 0 + self._last_error_hint = None # Human-readable hint for diagnostic display + + # State caching for display optimization + self.last_weather_state = None + self.last_hourly_state = None + self.last_daily_state = None + self.current_display_mode = None # Track current mode to detect switches + + # Internal mode cycling (similar to hockey plugin) + # Build list of enabled modes in order + self.modes = [] + if self.show_current: + self.modes.append('weather') + if self.show_hourly: + self.modes.append('hourly_forecast') + if self.show_daily: + self.modes.append('daily_forecast') + + # Default to first mode if none enabled + if not self.modes: + self.modes = ['weather'] + + self.current_mode_index = 0 + self.last_mode_switch = 0 + self.display_duration = config.get('display_duration', 30) + + # Layout constants + self.PADDING = 1 + self.COLORS = { + 'text': (255, 255, 255), + 'highlight': (255, 200, 0), + 'separator': (64, 64, 64), + 'temp_high': (255, 100, 100), + 'temp_low': (100, 100, 255), + 'dim': (180, 180, 180), + 'extra_dim': (120, 120, 120), + 'uv_low': (0, 150, 0), + 'uv_moderate': (255, 200, 0), + 'uv_high': (255, 120, 0), + 'uv_very_high': (200, 0, 0), + 'uv_extreme': (150, 0, 200) + } + + # Resolve project root path (plugin_dir -> plugins -> project_root) + self.project_root = Path(__file__).resolve().parent.parent.parent + + # Weather icons path (Note: WeatherIcons class resolves paths itself, this is just for reference) + self.icons_dir = self.project_root / 'assets' / 'weather' + + # Register fonts + self._register_fonts() + + self.logger.info(f"Weather plugin initialized for {self.location.get('city', 'Unknown')}") + self.logger.info(f"Units: {self.units}, Update interval: {self.update_interval}s") + + def _register_fonts(self): + """Register fonts with the font manager.""" + try: + if not hasattr(self.plugin_manager, 'font_manager') or self.plugin_manager.font_manager is None: + self.logger.warning("Font manager not available") + return + + font_manager = self.plugin_manager.font_manager + + # Register fonts for different elements + font_manager.register_manager_font( + manager_id=self.plugin_id, + element_key=f"{self.plugin_id}.temperature", + family="press_start", + size_px=16, + color=self.COLORS['text'] + ) + + font_manager.register_manager_font( + manager_id=self.plugin_id, + element_key=f"{self.plugin_id}.condition", + family="four_by_six", + size_px=8, + color=self.COLORS['highlight'] + ) + + font_manager.register_manager_font( + manager_id=self.plugin_id, + element_key=f"{self.plugin_id}.forecast_label", + family="four_by_six", + size_px=6, + color=self.COLORS['dim'] + ) + + self.logger.info("Weather plugin fonts registered successfully") + except Exception as e: + self.logger.warning(f"Error registering fonts: {e}") + + def _get_layout(self) -> dict: + """Return cached layout parameters (computed once on first call). + + Icon sizes scale proportionally with display height. + Text spacing stays fixed because fonts are fixed-size bitmaps. + Reference baseline: 128x32 display. + """ + if hasattr(self, '_layout_cache'): + return self._layout_cache + + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + h_scale = height / 32.0 + + # Fixed font metrics (do not change with display size) + small_font_h = 8 + extra_small_font_h = 7 + + margin = max(1, round(1 * h_scale)) + + # --- Current weather mode --- + current_icon_size = max(14, round(40 * h_scale)) + current_icon_x = margin + current_available_h = (height * 2) // 3 + current_icon_y = (current_available_h - current_icon_size) // 2 + + # Text rows on right side (fixed spacing since fonts are fixed) + condition_y = margin + temp_y = condition_y + small_font_h + high_low_y = temp_y + small_font_h + bottom_bar_y = height - extra_small_font_h + + # --- Forecast modes (hourly + daily) --- + # Scale with height but cap by narrowest column width to prevent overflow + min_column_width = width // 4 + forecast_icon_size = max(14, min(round(30 * h_scale), min_column_width)) + forecast_top_y = margin + forecast_icon_y = max(0, (height - forecast_icon_size) // 2) + forecast_bottom_y = height - small_font_h + + self._layout_cache = { + 'current_icon_size': current_icon_size, + 'current_icon_x': current_icon_x, + 'current_icon_y': current_icon_y, + 'condition_y': condition_y, + 'temp_y': temp_y, + 'high_low_y': high_low_y, + 'bottom_bar_y': bottom_bar_y, + 'right_margin': margin, + 'forecast_icon_size': forecast_icon_size, + 'forecast_top_y': forecast_top_y, + 'forecast_icon_y': forecast_icon_y, + 'forecast_bottom_y': forecast_bottom_y, + 'margin': margin, + } + return self._layout_cache + + def update(self) -> None: + """ + Update weather data from OpenWeatherMap API. + + Fetches current conditions and forecast data, respecting + update intervals and error backoff periods. + """ + current_time = time.time() + + # Check if we need to update + if current_time - self.last_update < self.update_interval: + return + + # Check if we're in error backoff period + if self.consecutive_errors >= self.max_consecutive_errors: + if current_time - self.last_error_time < self.error_backoff_time: + self.logger.debug(f"In error backoff period, retrying in {self.error_backoff_time - (current_time - self.last_error_time):.0f}s") + return + else: + # Reset error count after backoff + self.consecutive_errors = 0 + self.error_backoff_time = 60 + + # Validate API key + if not self.api_key or self.api_key == "YOUR_OPENWEATHERMAP_API_KEY": + self.logger.warning("No valid OpenWeatherMap API key configured") + return + + # Try to fetch weather data + try: + self._fetch_weather() + self.last_update = current_time + self.consecutive_errors = 0 + self._last_error_hint = None + except Exception as e: + self.consecutive_errors += 1 + self.last_error_time = current_time + if not self._last_error_hint: + self._last_error_hint = str(e)[:40] + + # Exponential backoff: double the backoff time (max 1 hour) + self.error_backoff_time = min(self.error_backoff_time * 2, 3600) + + # Only log errors periodically to avoid spam + if current_time - self.last_error_log_time > self.error_log_throttle: + self.logger.error(f"Error updating weather (attempt {self.consecutive_errors}/{self.max_consecutive_errors}): {e}") + if self.consecutive_errors >= self.max_consecutive_errors: + self.logger.error(f"Weather API disabled for {self.error_backoff_time} seconds due to repeated failures") + self.last_error_log_time = current_time + + def _fetch_weather(self) -> None: + """Fetch weather data from OpenWeatherMap API.""" + # Check cache first - use update_interval as max_age to respect configured refresh rate + cache_key = 'weather' + cached_data = self.cache_manager.get(cache_key, max_age=self.update_interval) + if cached_data: + self.weather_data = cached_data.get('current') + self.forecast_data = cached_data.get('forecast') + if self.weather_data and self.forecast_data: + self._process_forecast_data(self.forecast_data) + self.logger.info("Using cached weather data") + return + + # Fetch fresh data + city = self.location.get('city', 'Dallas') + state = self.location.get('state', 'Texas') + country = self.location.get('country', 'US') + + # Get coordinates using geocoding API + geo_url = f"http://api.openweathermap.org/geo/1.0/direct?q={city},{state},{country}&limit=1&appid={self.api_key}" + + try: + response = requests.get(geo_url, timeout=10) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else None + if status == 401: + self._last_error_hint = "Invalid API key" + self.logger.error( + "Geocoding API returned 401 Unauthorized. " + "Verify your API key is correct at https://openweathermap.org/api" + ) + elif status == 429: + self._last_error_hint = "Rate limit exceeded" + self.logger.error("Geocoding API rate limit exceeded (429). Increase update_interval.") + else: + self._last_error_hint = f"Geo API error {status}" + self.logger.error(f"Geocoding API HTTP error {status}: {e}") + raise + geo_data = response.json() + + # Increment API counter for geocoding call + increment_api_counter('weather', 1) + + if not geo_data: + self._last_error_hint = f"Unknown: {city}, {state}" + self.logger.error(f"Could not find coordinates for {city}, {state}, {country}") + self.last_update = time.time() # Prevent immediate retry + return + + lat = geo_data[0]['lat'] + lon = geo_data[0]['lon'] + + # Get weather data using One Call API + one_call_url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&exclude=minutely,alerts&appid={self.api_key}&units={self.units}" + + try: + response = requests.get(one_call_url, timeout=10) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else None + if status == 401: + self._last_error_hint = "Subscribe to One Call 3.0" + self.logger.error( + "One Call API 3.0 returned 401 Unauthorized. " + "Your API key is NOT subscribed to One Call API 3.0. " + "Subscribe (free tier available) at https://openweathermap.org/api " + "-> One Call API 3.0 -> Subscribe." + ) + elif status == 429: + self._last_error_hint = "Rate limit exceeded" + self.logger.error("One Call API rate limit exceeded (429). Increase update_interval.") + else: + self._last_error_hint = f"Weather API error {status}" + self.logger.error(f"One Call API HTTP error {status}: {e}") + raise + one_call_data = response.json() + + # Increment API counter for weather data call + increment_api_counter('weather', 1) + + # Store current weather data + self.weather_data = { + 'main': { + 'temp': one_call_data['current']['temp'], + 'temp_max': one_call_data['daily'][0]['temp']['max'], + 'temp_min': one_call_data['daily'][0]['temp']['min'], + 'humidity': one_call_data['current']['humidity'], + 'pressure': one_call_data['current']['pressure'], + 'uvi': one_call_data['current'].get('uvi', 0) + }, + 'weather': one_call_data['current']['weather'], + 'wind': { + 'speed': one_call_data['current']['wind_speed'], + 'deg': one_call_data['current'].get('wind_deg', 0) + } + } + + # Store forecast data + self.forecast_data = one_call_data + + # Process forecast data + self._process_forecast_data(self.forecast_data) + + # Cache the data + self.cache_manager.set(cache_key, { + 'current': self.weather_data, + 'forecast': self.forecast_data + }) + + self.logger.info(f"Weather data updated for {city}: {self.weather_data['main']['temp']}°") + + def _process_forecast_data(self, forecast_data: Dict) -> None: + """Process forecast data into hourly and daily lists.""" + if not forecast_data: + return + + # Process hourly forecast (next 5 hours, excluding current hour) + hourly_list = forecast_data.get('hourly', []) + + # Filter out the current hour - get current timestamp rounded down to the hour + current_time = time.time() + current_hour_timestamp = int(current_time // 3600) * 3600 # Round down to nearest hour + + # Filter out entries that are in the current hour or past + future_hourly = [ + hour_data for hour_data in hourly_list + if hour_data.get('dt', 0) > current_hour_timestamp + ] + + # Get next 5 hours + hourly_list = future_hourly[:5] + self.hourly_forecast = [] + + for hour_data in hourly_list: + dt = datetime.fromtimestamp(hour_data['dt']) + temp = round(hour_data['temp']) + condition = hour_data['weather'][0]['main'] + icon_code = hour_data['weather'][0]['icon'] + self.hourly_forecast.append({ + 'hour': dt.strftime('%I:00 %p').lstrip('0'), # Format as "2:00 PM" + 'temp': temp, + 'condition': condition, + 'icon': icon_code + }) + + # Process daily forecast + daily_list = forecast_data.get('daily', [])[1:4] # Skip today (index 0) and get next 3 days + self.daily_forecast = [] + + for day_data in daily_list: + dt = datetime.fromtimestamp(day_data['dt']) + temp_high = round(day_data['temp']['max']) + temp_low = round(day_data['temp']['min']) + condition = day_data['weather'][0]['main'] + icon_code = day_data['weather'][0]['icon'] + + self.daily_forecast.append({ + 'date': dt.strftime('%a'), # Day name (Mon, Tue, etc.) + 'date_str': dt.strftime('%m/%d'), # Date (4/8, 4/9, etc.) + 'temp_high': temp_high, + 'temp_low': temp_low, + 'condition': condition, + 'icon': icon_code + }) + + def display(self, display_mode: str = None, force_clear: bool = False) -> None: + """ + Display weather information with internal mode cycling. + + The display controller registers each mode separately (weather, hourly_forecast, daily_forecast) + but calls display() without passing the mode name. This plugin handles mode cycling internally + similar to the hockey plugin, advancing through enabled modes based on time. + + Args: + display_mode: Optional mode name (not currently used, kept for compatibility) + force_clear: If True, clear the display before rendering (ignored, kept for compatibility) + """ + if not self.weather_data: + self._display_no_data() + return + + # Note: force_clear is handled by display_manager, not needed here + # This parameter is kept for compatibility with BasePlugin interface + + current_mode = None + + # If a specific mode is requested (compatibility methods), honor it + if display_mode and display_mode in self.modes: + try: + requested_index = self.modes.index(display_mode) + except ValueError: + requested_index = None + + if requested_index is not None: + current_mode = self.modes[requested_index] + if current_mode != self.current_display_mode: + self.current_mode_index = requested_index + self._on_mode_changed(current_mode) + else: + # Default rotation synchronized with display controller + if self.current_display_mode is None: + current_mode = self.modes[self.current_mode_index] + self._on_mode_changed(current_mode) + elif force_clear: + self.current_mode_index = (self.current_mode_index + 1) % len(self.modes) + current_mode = self.modes[self.current_mode_index] + self._on_mode_changed(current_mode) + else: + current_mode = self.modes[self.current_mode_index] + + # Ensure we have a mode even if none of the above paths triggered a change + if current_mode is None: + current_mode = self.current_display_mode or self.modes[self.current_mode_index] + + # Display the current mode + if current_mode == 'hourly_forecast' and self.show_hourly: + self._display_hourly_forecast() + elif current_mode == 'daily_forecast' and self.show_daily: + self._display_daily_forecast() + elif current_mode == 'weather' and self.show_current: + self._display_current_weather() + else: + # Fallback: show current weather if mode doesn't match + self.logger.warning(f"Mode {current_mode} not available, showing current weather") + self._display_current_weather() + + def _on_mode_changed(self, new_mode: str) -> None: + """Handle logic needed when switching display modes.""" + if new_mode == self.current_display_mode: + return + + self.logger.info(f"Display mode changed from {self.current_display_mode} to {new_mode}") + if new_mode == 'hourly_forecast': + self.last_hourly_state = None + self.logger.debug("Reset hourly state cache for mode switch") + elif new_mode == 'daily_forecast': + self.last_daily_state = None + self.logger.debug("Reset daily state cache for mode switch") + else: + self.last_weather_state = None + self.logger.debug("Reset weather state cache for mode switch") + + self.current_display_mode = new_mode + self.last_mode_switch = time.time() + + def _display_no_data(self) -> None: + """Display a diagnostic message when no weather data is available.""" + img = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + from PIL import ImageFont + try: + font_path = self.project_root / 'assets' / 'fonts' / '4x6-font.ttf' + font = ImageFont.truetype(str(font_path), 8) + except Exception: + font = ImageFont.load_default() + + if not self.api_key or self.api_key == "YOUR_OPENWEATHERMAP_API_KEY": + draw.text((2, 8), "Weather:", font=font, fill=(200, 200, 200)) + draw.text((2, 18), "No API Key", font=font, fill=(255, 100, 100)) + elif self._last_error_hint: + draw.text((2, 4), "Weather Err", font=font, fill=(200, 200, 200)) + hint = self._last_error_hint[:22] + draw.text((2, 14), hint, font=font, fill=(255, 100, 100)) + else: + draw.text((5, 8), "No Weather", font=font, fill=(200, 200, 200)) + draw.text((5, 18), "Data", font=font, fill=(200, 200, 200)) + + self.display_manager.image = img + self.display_manager.update_display() + + def _render_current_weather_image(self) -> Optional[Image.Image]: + """Render current weather conditions to an Image without display side effects.""" + try: + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + img = Image.new('RGB', (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Get weather info + temp = int(self.weather_data['main']['temp']) + condition = self.weather_data['weather'][0]['main'] + icon_code = self.weather_data['weather'][0]['icon'] + humidity = self.weather_data['main']['humidity'] + wind_speed = self.weather_data['wind'].get('speed', 0) + wind_deg = self.weather_data['wind'].get('deg', 0) + uv_index = self.weather_data['main'].get('uvi', 0) + temp_high = int(self.weather_data['main']['temp_max']) + temp_low = int(self.weather_data['main']['temp_min']) + + layout = self._get_layout() + + # --- Top Left: Weather Icon --- + icon_size = layout['current_icon_size'] + icon_x = layout['current_icon_x'] + icon_y = layout['current_icon_y'] + WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y, size=icon_size) + + # --- Top Right: Condition Text --- + condition_font = self.display_manager.small_font + condition_text_width = draw.textlength(condition, font=condition_font) + condition_x = width - condition_text_width - layout['right_margin'] + condition_y = layout['condition_y'] + draw.text((condition_x, condition_y), condition, font=condition_font, fill=self.COLORS['text']) + + # --- Right Side: Current Temperature --- + temp_text = f"{temp}°" + temp_font = self.display_manager.small_font + temp_text_width = draw.textlength(temp_text, font=temp_font) + temp_x = width - temp_text_width - layout['right_margin'] + temp_y = layout['temp_y'] + draw.text((temp_x, temp_y), temp_text, font=temp_font, fill=self.COLORS['highlight']) + + # --- Right Side: High/Low Temperature --- + high_low_text = f"{temp_low}°/{temp_high}°" + high_low_font = self.display_manager.small_font + high_low_width = draw.textlength(high_low_text, font=high_low_font) + high_low_x = width - high_low_width - layout['right_margin'] + high_low_y = layout['high_low_y'] + draw.text((high_low_x, high_low_y), high_low_text, font=high_low_font, fill=self.COLORS['dim']) + + # --- Bottom: Additional Metrics --- + section_width = width // 3 + y_pos = layout['bottom_bar_y'] + font = self.display_manager.extra_small_font + + # UV Index (Section 1) + uv_prefix = "UV:" + uv_value_text = f"{uv_index:.0f}" + prefix_width = draw.textlength(uv_prefix, font=font) + value_width = draw.textlength(uv_value_text, font=font) + total_width = prefix_width + value_width + start_x = (section_width - total_width) // 2 + draw.text((start_x, y_pos), uv_prefix, font=font, fill=self.COLORS['dim']) + uv_color = self._get_uv_color(uv_index) + draw.text((start_x + prefix_width, y_pos), uv_value_text, font=font, fill=uv_color) + + # Humidity (Section 2) + humidity_text = f"H:{humidity}%" + humidity_width = draw.textlength(humidity_text, font=font) + humidity_x = section_width + (section_width - humidity_width) // 2 + draw.text((humidity_x, y_pos), humidity_text, font=font, fill=self.COLORS['dim']) + + # Wind (Section 3) + wind_dir = self._get_wind_direction(wind_deg) + wind_text = f"W:{wind_speed:.0f}{wind_dir}" + wind_width = draw.textlength(wind_text, font=font) + wind_x = (2 * section_width) + (section_width - wind_width) // 2 + draw.text((wind_x, y_pos), wind_text, font=font, fill=self.COLORS['dim']) + + return img + except Exception as e: + self.logger.exception("Error rendering current weather") + return None + + def _display_current_weather(self) -> None: + """Display current weather conditions using comprehensive layout with icons.""" + try: + current_state = self._get_weather_state() + if current_state == self.last_weather_state: + self.display_manager.update_display() + return + + self.display_manager.clear() + img = self._render_current_weather_image() + if img: + self.display_manager.image = img + self.display_manager.update_display() + self.last_weather_state = current_state + except Exception as e: + self.logger.error(f"Error displaying current weather: {e}") + + def _get_wind_direction(self, degrees: float) -> str: + """Convert wind degrees to cardinal direction.""" + directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] + index = round(degrees / 45) % 8 + return directions[index] + + def _get_uv_color(self, uv_index: float) -> tuple: + """Get color based on UV index value.""" + if uv_index <= 2: + return self.COLORS['uv_low'] + elif uv_index <= 5: + return self.COLORS['uv_moderate'] + elif uv_index <= 7: + return self.COLORS['uv_high'] + elif uv_index <= 10: + return self.COLORS['uv_very_high'] + else: + return self.COLORS['uv_extreme'] + + def _get_weather_state(self) -> Dict[str, Any]: + """Get current weather state for comparison.""" + if not self.weather_data: + return None + return { + 'temp': round(self.weather_data['main']['temp']), + 'condition': self.weather_data['weather'][0]['main'], + 'humidity': self.weather_data['main']['humidity'], + 'uvi': self.weather_data['main'].get('uvi', 0) + } + + def _get_hourly_state(self) -> List[Dict[str, Any]]: + """Get current hourly forecast state for comparison.""" + if not self.hourly_forecast: + return None + return [ + {'hour': f['hour'], 'temp': round(f['temp']), 'condition': f['condition']} + for f in self.hourly_forecast[:3] + ] + + def _get_daily_state(self) -> List[Dict[str, Any]]: + """Get current daily forecast state for comparison.""" + if not self.daily_forecast: + return None + return [ + { + 'date': f['date'], + 'temp_high': round(f['temp_high']), + 'temp_low': round(f['temp_low']), + 'condition': f['condition'] + } + for f in self.daily_forecast[:4] + ] + + def _render_hourly_forecast_image(self) -> Optional[Image.Image]: + """Render hourly forecast to an Image without display side effects.""" + try: + if not self.hourly_forecast: + return None + + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + img = Image.new('RGB', (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + layout = self._get_layout() + hours_to_show = min(4, len(self.hourly_forecast)) + section_width = width // hours_to_show + padding = max(2, section_width // 6) + + for i in range(hours_to_show): + forecast = self.hourly_forecast[i] + x = i * section_width + padding + center_x = x + (section_width - 2 * padding) // 2 + + # Hour at top + hour_text = forecast['hour'] + hour_text = hour_text.replace(":00 ", "").replace("PM", "p").replace("AM", "a") + hour_width = draw.textlength(hour_text, font=self.display_manager.small_font) + draw.text((center_x - hour_width // 2, layout['forecast_top_y']), + hour_text, + font=self.display_manager.small_font, + fill=self.COLORS['text']) + + # Weather icon + icon_size = layout['forecast_icon_size'] + icon_y = layout['forecast_icon_y'] + icon_x = center_x - icon_size // 2 + WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size) + + # Temperature at bottom + temp_text = f"{forecast['temp']}°" + temp_width = draw.textlength(temp_text, font=self.display_manager.small_font) + temp_y = layout['forecast_bottom_y'] + draw.text((center_x - temp_width // 2, temp_y), + temp_text, + font=self.display_manager.small_font, + fill=self.COLORS['text']) + + return img + except Exception as e: + self.logger.exception("Error rendering hourly forecast") + return None + + def _display_hourly_forecast(self) -> None: + """Display hourly forecast with weather icons.""" + try: + if not self.hourly_forecast: + self.logger.warning("No hourly forecast data available, showing no data message") + self._display_no_data() + return + + current_state = self._get_hourly_state() + if current_state == self.last_hourly_state: + self.display_manager.update_display() + return + + self.display_manager.clear() + img = self._render_hourly_forecast_image() + if img: + self.display_manager.image = img + self.display_manager.update_display() + self.last_hourly_state = current_state + except Exception as e: + self.logger.error(f"Error displaying hourly forecast: {e}") + + def _render_daily_forecast_image(self) -> Optional[Image.Image]: + """Render daily forecast to an Image without display side effects.""" + try: + if not self.daily_forecast: + return None + + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + img = Image.new('RGB', (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + layout = self._get_layout() + days_to_show = min(3, len(self.daily_forecast)) + if days_to_show == 0: + draw.text((2, 2), "No daily forecast", font=self.display_manager.small_font, fill=self.COLORS['dim']) + else: + section_width = width // days_to_show + + for i in range(days_to_show): + forecast = self.daily_forecast[i] + center_x = i * section_width + section_width // 2 + + # Day name at top + day_text = forecast['date'] + day_width = draw.textlength(day_text, font=self.display_manager.small_font) + draw.text((center_x - day_width // 2, layout['forecast_top_y']), + day_text, + font=self.display_manager.small_font, + fill=self.COLORS['text']) + + # Weather icon + icon_size = layout['forecast_icon_size'] + icon_y = layout['forecast_icon_y'] + icon_x = center_x - icon_size // 2 + WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size) + + # High/low temperatures at bottom + temp_text = f"{forecast['temp_low']} / {forecast['temp_high']}" + temp_width = draw.textlength(temp_text, font=self.display_manager.extra_small_font) + temp_y = layout['forecast_bottom_y'] + draw.text((center_x - temp_width // 2, temp_y), + temp_text, + font=self.display_manager.extra_small_font, + fill=self.COLORS['text']) + + return img + except Exception as e: + self.logger.exception("Error rendering daily forecast") + return None + + def _display_daily_forecast(self) -> None: + """Display daily forecast with weather icons.""" + try: + if not self.daily_forecast: + self._display_no_data() + return + + current_state = self._get_daily_state() + if current_state == self.last_daily_state: + self.display_manager.update_display() + return + + self.display_manager.clear() + img = self._render_daily_forecast_image() + if img: + self.display_manager.image = img + self.display_manager.update_display() + self.last_daily_state = current_state + except Exception as e: + self.logger.error(f"Error displaying daily forecast: {e}") + + def get_vegas_content(self): + """Return images for all enabled weather display modes.""" + if not self.weather_data: + return None + + images = [] + + if self.show_current: + img = self._render_current_weather_image() + if img: + images.append(img) + + if self.show_hourly and self.hourly_forecast: + img = self._render_hourly_forecast_image() + if img: + images.append(img) + + if self.show_daily and self.daily_forecast: + img = self._render_daily_forecast_image() + if img: + images.append(img) + + if images: + total_width = sum(img.width for img in images) + self.logger.info( + "[Weather Vegas] Returning %d image(s), %dpx total", + len(images), total_width + ) + return images + + return None + + def display_weather(self, force_clear: bool = False) -> None: + """Display current weather (compatibility method for display controller).""" + self.display('weather', force_clear) + + def display_hourly_forecast(self, force_clear: bool = False) -> None: + """Display hourly forecast (compatibility method for display controller).""" + self.display('hourly_forecast', force_clear) + + def display_daily_forecast(self, force_clear: bool = False) -> None: + """Display daily forecast (compatibility method for display controller).""" + self.display('daily_forecast', force_clear) + + def get_info(self) -> Dict[str, Any]: + """Return plugin info for web UI.""" + info = super().get_info() + info.update({ + 'location': self.location, + 'units': self.units, + 'api_key_configured': bool(self.api_key), + 'last_update': self.last_update, + 'current_temp': self.weather_data.get('main', {}).get('temp') if self.weather_data else None, + 'current_humidity': self.weather_data.get('main', {}).get('humidity') if self.weather_data else None, + 'current_description': self.weather_data.get('weather', [{}])[0].get('description', '') if self.weather_data else '', + 'forecast_available': bool(self.forecast_data), + 'daily_forecast_count': len(self.daily_forecast) if hasattr(self, 'daily_forecast') and self.daily_forecast is not None else 0, + 'hourly_forecast_count': len(self.hourly_forecast) if hasattr(self, 'hourly_forecast') and self.hourly_forecast is not None else 0 + }) + return info + + def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Reload all config-derived attributes when settings change via web UI.""" + super().on_config_change(new_config) + + self.api_key = new_config.get('api_key', 'YOUR_OPENWEATHERMAP_API_KEY') + self.location = { + 'city': new_config.get('location_city', 'Dallas'), + 'state': new_config.get('location_state', 'Texas'), + 'country': new_config.get('location_country', 'US') + } + self.units = new_config.get('units', 'imperial') + update_interval = new_config.get('update_interval', 1800) + try: + self.update_interval = int(update_interval) + except (ValueError, TypeError): + self.update_interval = 1800 + + self.show_current = new_config.get('show_current_weather', True) + self.show_hourly = new_config.get('show_hourly_forecast', True) + self.show_daily = new_config.get('show_daily_forecast', True) + self.display_duration = new_config.get('display_duration', 30) + + # Rebuild the enabled modes list + self.modes = [] + if self.show_current: + self.modes.append('weather') + if self.show_hourly: + self.modes.append('hourly_forecast') + if self.show_daily: + self.modes.append('daily_forecast') + if not self.modes: + self.modes = ['weather'] + + # Reset update timer and error state so new settings take effect immediately + self.last_update = 0 + self.consecutive_errors = 0 + self.error_backoff_time = 60 + + # Clear layout cache since units/display settings may have changed + if hasattr(self, '_layout_cache'): + del self._layout_cache + + self.logger.info( + "Weather plugin config reloaded: city=%s, units=%s, api_key_set=%s", + self.location.get('city'), + self.units, + bool(self.api_key and self.api_key != 'YOUR_OPENWEATHERMAP_API_KEY') + ) + + def cleanup(self) -> None: + """Cleanup resources.""" + self.weather_data = None + self.forecast_data = None + self.logger.info("Weather plugin cleaned up") + diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index bd9f6cb15..cb1fdd4b3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -2523,6 +2523,30 @@ def get_plugin_config(): 'display_duration': 30 } + # 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 @@ -4625,6 +4649,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 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 != '': + 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') From 18c0591540432829443ce005fa6472a7a90164ad Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:29:35 -0500 Subject: [PATCH 03/14] fix(web): add custom-html widget support, fix secrets handling, hide internal plugin actions - Add server-side rendering for x-widget: "custom-html" in plugin config template, enabling plugins like of-the-day to load custom file manager UIs - Hide internal web_ui_actions (those without a title) from the Plugin Actions section - they're API-only actions used by custom widgets - Mask x-secret fields in GET plugin config and skip empty secrets on POST to prevent exposing/overwriting API keys - Fix config_secrets template to use plugin_id key ("ledmatrix-weather") Co-Authored-By: Claude Opus 4.6 --- config/config_secrets.template.json | 2 +- web_interface/blueprints/api_v3.py | 147 +++++++++++------- web_interface/blueprints/pages_v3.py | 33 ++++ .../templates/v3/partials/plugin_config.html | 76 ++++++++- 4 files changed, 193 insertions(+), 65 deletions(-) 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/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index bd9f6cb15..c700c43f7 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -201,12 +201,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 @@ -652,11 +653,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() @@ -895,8 +891,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: @@ -1014,13 +1010,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 isinstance(v, str) and v and not 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 @@ -2520,9 +2530,44 @@ def get_plugin_config(): if not plugin_config: plugin_config = { 'enabled': True, - 'display_duration': 30 + 'display_duration': 15 } + # Mask secret fields (x-secret: true in schema) so API keys are not + # returned in plain text. Empty string signals "not set" to the UI; + # the POST handler will preserve existing secrets when empty is submitted. + if schema_mgr: + try: + schema = schema_mgr.load_schema(plugin_id, use_cache=True) + if schema and 'properties' in schema: + 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] = '' # Replace secret value with empty string + else: + masked[key] = value + return masked + + secret_fields = _find_secret_fields(schema['properties']) + if secret_fields: + plugin_config = _mask_secrets(plugin_config, secret_fields) + except Exception: + pass # Best effort — don't fail the request if masking errors + return success_response(data=plugin_config) except Exception as e: from src.web_interface.errors import WebInterfaceError @@ -2546,18 +2591,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', @@ -4534,20 +4574,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: @@ -4557,31 +4588,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', @@ -4612,8 +4631,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: @@ -4633,17 +4652,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 v != '': + filtered[k] = v + return filtered + secrets_config = _filter_empty_secrets(secrets_config) # Deep merge plugin secrets in secrets config if secrets_config: @@ -4859,8 +4882,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: @@ -4889,8 +4912,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) diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 9d53a523f..b7365fdd6 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -384,6 +384,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: + pass # Best effort — don't fail the render if masking errors # 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 c18862cdc..2cf626471 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 #} @@ -775,15 +828,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 #}
+
+ + +
+ +

Drag and drop JSON files here

+

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

+ +
+ + +
+
+
+

Loading files...

+
+
+ + + + + + + + + + + + + + + From 7e08547211f63b0bfb0c39b4f28796803669d098 Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:44:22 -0500 Subject: [PATCH 06/14] fix(of-the-day): auto-register category in config on first toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toggle_category.py was returning HTTP 400 "Category not found in config" for any JSON file that existed on disk but had no entry in config/config.json under of-the-day.categories (e.g. manually-placed files or files added before the upload workflow ran add_category_to_config). list_files.py already displays such files with enabled=True as a default, so the toggle switch was visible but non-functional. Fix: if the category is missing from config when a toggle arrives, auto-register it (enabled=True, data_file, display_name derived from filename) and add it to category_order — matching what upload_file.py does via update_config.py — then apply the requested enabled state. Co-Authored-By: Claude Sonnet 4.6 --- .../of-the-day/scripts/toggle_category.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 plugin-repos/of-the-day/scripts/toggle_category.py 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..0c5f8bc93 --- /dev/null +++ b/plugin-repos/of-the-day/scripts/toggle_category.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Toggle a category's enabled status. +Receives category_name and optional enabled state via stdin as JSON. +""" + +import os +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) + +# 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 + new_enabled = bool(params['enabled']) +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 Exception 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 +})) From 98ed7327260df69284a69aece423b1b23c58cffe Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:45:50 -0500 Subject: [PATCH 07/14] fix(api): remove stale wrapper_path reference in timeout handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leftover from the wrapper-script removal — wrapper_path no longer exists at this point in the code path so the os.unlink() was a no-op at best and a NameError at worst. Co-Authored-By: Claude Sonnet 4.6 --- web_interface/blueprints/api_v3.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index d9ce5c27d..701837f2b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -5135,8 +5135,6 @@ def execute_plugin_action(): '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 From 0b75e2995af96851a05e7e43f1ec1c74b71066c8 Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:57:29 -0500 Subject: [PATCH 08/14] fix(of-the-day): load data files for categories missing from categories config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _load_data_files() only iterated self.categories.items(), so any category listed in category_order but absent from the categories dict (e.g. word_of_the_day, bible_verse_of_the_day) was silently skipped, leaving current_items empty and causing the plugin to show "No Data". Fix: build a unified load list that merges self.categories with any entries in self.category_order that lack a config entry, auto-deriving the data_file path as of_the_day/.json. Explicit config entries always take priority; auto-discovered ones default to enabled=True. Also registered word_of_the_day and bible_verse_of_the_day in config/config.json so the categories dict is consistent with category_order (config.json is not committed — fixed on device). Co-Authored-By: Claude Sonnet 4.6 --- plugin-repos/of-the-day/manager.py | 759 +++++++++++++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 plugin-repos/of-the-day/manager.py diff --git a/plugin-repos/of-the-day/manager.py b/plugin-repos/of-the-day/manager.py new file mode 100644 index 000000000..c85f3c46f --- /dev/null +++ b/plugin-repos/of-the-day/manager.py @@ -0,0 +1,759 @@ +""" +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 logging +import time +from datetime import date +from typing import Dict, Any, List, Optional +from PIL import Image, ImageDraw, ImageFont +from pathlib import Path + +from src.plugin_system.base_plugin import BasePlugin + +logger = logging.getLogger(__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 = config.get('update_interval', 3600) + self.display_rotate_interval = config.get('display_rotate_interval', 20) + self.subtitle_rotate_interval = config.get('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 = (200, 200, 200) + self.content_color = (180, 180, 180) + self.background_color = (0, 0, 0) + + # 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 _register_fonts(self): + """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() + + try: + body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) + except Exception as e: + self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") + body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else 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 + + # Layout matching old manager: margin_top = 8 + margin_top = 8 + 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=self.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=self.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=self.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 - match old manager + try: + title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) + except: + title_font = self.display_manager.small_font if hasattr(self.display_manager, 'small_font') else ImageFont.load_default() + + try: + body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) + except: + body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else ImageFont.load_default() + + # 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 + + # Layout matching old manager: margin_top = 8 + margin_top = 8 + 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=self.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=self.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(2, int(extra_space * 0.3)) + 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 + space_after_underline = 4 + space_between_lines = 1 + + # Draw body text with dynamic spacing + body_start_y = underline_y + space_after_underline + underline_space + 1 # +1 to match old manager's shift + 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=self.subtitle_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.""" + 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: + 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.""" + 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: + 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 = config.get('update_interval', 3600) + self.display_rotate_interval = config.get('display_rotate_interval', 20) + self.subtitle_rotate_interval = config.get('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") + From debdfd4f6a258aa25c2959b638e1a49eba7a30aa Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:04:19 -0500 Subject: [PATCH 09/14] chore: remove plugin files from LEDMatrix repo (belong in plugin repos) plugins/stocks/ and plugins/weather/ were accidentally committed to the LEDMatrix repo, bypassing the plugins/.gitignore rule. Plugin source code belongs in the ledmatrix-plugins/ledmatrix-stocks/ledmatrix-weather repos. The api_v3.py changes from the same commit (secrets masking/filtering) are intentionally kept here as they belong to the web interface. Co-Authored-By: Claude Sonnet 4.6 --- plugins/stocks/display_renderer.py | 537 --------------- plugins/stocks/manager.py | 420 ------------ plugins/weather/manager.py | 1002 ---------------------------- 3 files changed, 1959 deletions(-) delete mode 100644 plugins/stocks/display_renderer.py delete mode 100644 plugins/stocks/manager.py delete mode 100644 plugins/weather/manager.py diff --git a/plugins/stocks/display_renderer.py b/plugins/stocks/display_renderer.py deleted file mode 100644 index 0b2667c79..000000000 --- a/plugins/stocks/display_renderer.py +++ /dev/null @@ -1,537 +0,0 @@ -""" -Display Renderer for Stock Ticker Plugin - -Handles all display creation, layout, and rendering logic for both -scrolling and static display modes. -""" - -import os -from typing import Dict, Any, List, Optional, Tuple -from PIL import Image, ImageDraw, ImageFont - -# Import common utilities -from src.common import ScrollHelper, LogoHelper, TextHelper - - -class StockDisplayRenderer: - """Handles rendering of stock and cryptocurrency displays.""" - - def __init__(self, config: Dict[str, Any], display_width: int, display_height: int, logger): - """Initialize the display renderer.""" - self.config = config - self.display_width = display_width - self.display_height = display_height - self.logger = logger - - # Display configuration - self.toggle_chart = config.get('display', {}).get('toggle_chart', True) - - # Load colors from customization structure (organized by element: symbol, price, price_delta) - # Support both new format (customization.stocks.*) and old format (top-level) for backwards compatibility - customization = config.get('customization', {}) - stocks_custom = customization.get('stocks', {}) - crypto_custom = customization.get('crypto', {}) - - # Stock colors - new format: customization.stocks.symbol/price/price_delta - # Old format fallback: top-level text_color, positive_color, negative_color - # Ensure all color values are integers (RGB values from config might be floats) - if stocks_custom.get('symbol') and 'text_color' in stocks_custom['symbol']: - # New format: separate colors for symbol and price - symbol_color_list = stocks_custom['symbol'].get('text_color', [255, 255, 255]) - price_color_list = stocks_custom.get('price', {}).get('text_color', [255, 255, 255]) - self.symbol_text_color = tuple(int(c) for c in symbol_color_list) - self.price_text_color = tuple(int(c) for c in price_color_list) - else: - # Old format: shared text_color for symbol and price - old_text_color_list = config.get('text_color', [255, 255, 255]) - old_text_color = tuple(int(c) for c in old_text_color_list) - self.symbol_text_color = old_text_color - self.price_text_color = old_text_color - - price_delta_custom = stocks_custom.get('price_delta', {}) - if price_delta_custom: - positive_color_list = price_delta_custom.get('positive_color', [0, 255, 0]) - negative_color_list = price_delta_custom.get('negative_color', [255, 0, 0]) - self.positive_color = tuple(int(c) for c in positive_color_list) - self.negative_color = tuple(int(c) for c in negative_color_list) - else: - # Old format fallback - positive_color_list = config.get('positive_color', [0, 255, 0]) - negative_color_list = config.get('negative_color', [255, 0, 0]) - self.positive_color = tuple(int(c) for c in positive_color_list) - self.negative_color = tuple(int(c) for c in negative_color_list) - - # Crypto colors - new format: customization.crypto.symbol/price/price_delta - # Old format fallback: customization.crypto.text_color, etc. - if crypto_custom.get('symbol') and 'text_color' in crypto_custom['symbol']: - # New format: separate colors for symbol and price - crypto_symbol_color_list = crypto_custom['symbol'].get('text_color', [255, 215, 0]) - crypto_price_color_list = crypto_custom.get('price', {}).get('text_color', [255, 215, 0]) - self.crypto_symbol_text_color = tuple(int(c) for c in crypto_symbol_color_list) - self.crypto_price_text_color = tuple(int(c) for c in crypto_price_color_list) - else: - # Old format: shared text_color for symbol and price - old_crypto_text_color_list = crypto_custom.get('text_color', [255, 215, 0]) - old_crypto_text_color = tuple(int(c) for c in old_crypto_text_color_list) - self.crypto_symbol_text_color = old_crypto_text_color - self.crypto_price_text_color = old_crypto_text_color - - crypto_price_delta_custom = crypto_custom.get('price_delta', {}) - if crypto_price_delta_custom: - crypto_positive_color_list = crypto_price_delta_custom.get('positive_color', [0, 255, 0]) - crypto_negative_color_list = crypto_price_delta_custom.get('negative_color', [255, 0, 0]) - self.crypto_positive_color = tuple(int(c) for c in crypto_positive_color_list) - self.crypto_negative_color = tuple(int(c) for c in crypto_negative_color_list) - else: - # Old format fallback - crypto_positive_color_list = crypto_custom.get('positive_color', [0, 255, 0]) - crypto_negative_color_list = crypto_custom.get('negative_color', [255, 0, 0]) - self.crypto_positive_color = tuple(int(c) for c in crypto_positive_color_list) - self.crypto_negative_color = tuple(int(c) for c in crypto_negative_color_list) - - # Initialize helpers - self.logo_helper = LogoHelper(display_width, display_height, logger=logger) - self.text_helper = TextHelper(logger=self.logger) - - # Initialize scroll helper - self.scroll_helper = ScrollHelper(display_width, display_height, logger) - - # Load custom fonts from config - # Fonts are under customization.stocks/crypto.symbol/price/price_delta - # For backwards compatibility, try to load from customization.fonts first - fonts_config = customization.get('fonts', {}) - if fonts_config: - # Old format: fonts at customization.fonts level (shared for stocks and crypto) - self.symbol_font = self._load_custom_font_from_element_config(fonts_config.get('symbol', {})) - self.price_font = self._load_custom_font_from_element_config(fonts_config.get('price', {})) - self.price_delta_font = self._load_custom_font_from_element_config(fonts_config.get('price_delta', {})) - else: - # New format: fonts at customization.stocks/crypto.symbol/price/price_delta - # Use stocks font config (crypto can override later if needed, but currently shares fonts) - stocks_custom = customization.get('stocks', {}) - self.symbol_font = self._load_custom_font_from_element_config(stocks_custom.get('symbol', {})) - self.price_font = self._load_custom_font_from_element_config(stocks_custom.get('price', {})) - self.price_delta_font = self._load_custom_font_from_element_config(stocks_custom.get('price_delta', {})) - - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any]) -> ImageFont.FreeTypeFont: - """ - Load a custom font from an element configuration dictionary. - - Args: - element_config: Configuration dict for a single element (symbol, price, or price_delta) - containing 'font' and 'font_size' keys - - Returns: - PIL ImageFont object - """ - # Get font name and size, with defaults - font_name = element_config.get('font', 'PressStart2P-Regular.ttf') - font_size = int(element_config.get('font_size', 8)) # Ensure integer for PIL - - # Build font path - font_path = os.path.join('assets', 'fonts', font_name) - - # Try to load the font - try: - if os.path.exists(font_path): - # Try loading as TTF first (works for both TTF and some BDF files with PIL) - if font_path.lower().endswith('.ttf'): - font = ImageFont.truetype(font_path, font_size) - self.logger.debug(f"Loaded font: {font_name} at size {font_size}") - return font - elif font_path.lower().endswith('.bdf'): - # PIL's ImageFont.truetype() can sometimes handle BDF files - # If it fails, we'll fall through to the default font - try: - font = ImageFont.truetype(font_path, font_size) - self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") - return font - except Exception: - self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") - # Fall through to default - else: - self.logger.warning(f"Unknown font file type: {font_name}, using default") - else: - self.logger.warning(f"Font file not found: {font_path}, using default") - except Exception as e: - self.logger.error(f"Error loading font {font_name}: {e}, using default") - - # Fall back to default font - default_font_path = os.path.join('assets', 'fonts', 'PressStart2P-Regular.ttf') - try: - if os.path.exists(default_font_path): - return ImageFont.truetype(default_font_path, font_size) - else: - self.logger.warning("Default font not found, using PIL default") - return ImageFont.load_default() - except Exception as e: - self.logger.error(f"Error loading default font: {e}") - return ImageFont.load_default() - - def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image: - """Create a display image for a single stock or crypto - matching old stock manager layout exactly.""" - # Create a wider image for scrolling - adjust width based on chart toggle - # Match old stock_manager: width = int(self.display_manager.matrix.width * (2 if self.toggle_chart else 1.5)) - # Ensure dimensions are integers - width = int(self.display_width * (2 if self.toggle_chart else 1.5)) - height = int(self.display_height) - image = Image.new('RGB', (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(image) - - is_crypto = data.get('is_crypto', False) - - # Draw large stock/crypto logo on the left - logo = self._get_stock_logo(symbol, is_crypto) - if logo: - # Position logo on the left side with minimal spacing - matching old stock_manager - # Ensure positions are integers - logo_x = 2 # Small margin from left edge - logo_y = int((height - logo.height) // 2) - image.paste(logo, (int(logo_x), int(logo_y)), logo) - - # Use custom fonts loaded from config - symbol_font = self.symbol_font - price_font = self.price_font - change_font = self.price_delta_font - - # Create text elements - display_symbol = symbol.replace('-USD', '') if is_crypto else symbol - symbol_text = display_symbol - price_text = f"${data['price']:.2f}" - - # Build change text based on show_change and show_percentage flags - # Get flags from config (stock-specific or crypto-specific) - if is_crypto: - show_change = self.config.get('crypto', {}).get('show_change', True) - show_percentage = self.config.get('crypto', {}).get('show_percentage', True) - else: - show_change = self.config.get('show_change', True) - show_percentage = self.config.get('show_percentage', True) - - # Build change text components - change_parts = [] - if show_change: - change_parts.append(f"{data['change']:+.2f}") - if show_percentage: - # Use change_percent if available, otherwise calculate from change and open - if 'change_percent' in data: - change_parts.append(f"({data['change_percent']:+.1f}%)") - elif 'open' in data and data['open'] > 0: - change_percent = (data['change'] / data['open']) * 100 - change_parts.append(f"({change_percent:+.1f}%)") - - change_text = " ".join(change_parts) if change_parts else "" - - # Get colors based on change - if data['change'] >= 0: - change_color = self.positive_color if not is_crypto else self.crypto_positive_color - else: - change_color = self.negative_color if not is_crypto else self.crypto_negative_color - - # Use symbol color for symbol, price color for price - symbol_color = self.symbol_text_color if not is_crypto else self.crypto_symbol_text_color - price_color = self.price_text_color if not is_crypto else self.crypto_price_text_color - - # Calculate text dimensions for proper spacing (matching old stock manager) - symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font) - price_bbox = draw.textbbox((0, 0), price_text, font=price_font) - - # Only calculate change_bbox if change_text is not empty - if change_text: - change_bbox = draw.textbbox((0, 0), change_text, font=change_font) - change_height = int(change_bbox[3] - change_bbox[1]) - else: - change_bbox = (0, 0, 0, 0) - change_height = 0 - - # Calculate total height needed - adjust gaps based on chart toggle - # Match old stock_manager: text_gap = 2 if self.toggle_chart else 1 - text_gap = 2 if self.toggle_chart else 1 - # Only add change height and gap if change is shown - change_gap = text_gap if change_text else 0 - symbol_height = int(symbol_bbox[3] - symbol_bbox[1]) - price_height = int(price_bbox[3] - price_bbox[1]) - total_text_height = symbol_height + price_height + change_height + (text_gap + change_gap) # Account for gaps between elements - - # Calculate starting y position to center all text - start_y = int((height - total_text_height) // 2) - - # Calculate center x position for the column - adjust based on chart toggle - # Match old stock_manager exactly - if self.toggle_chart: - # When chart is enabled, center text more to the left - column_x = int(width / 2.85) - else: - # When chart is disabled, position text with more space from logo - column_x = int(width / 2.2) - - # Draw symbol - symbol_width = int(symbol_bbox[2] - symbol_bbox[0]) - symbol_x = int(column_x - (symbol_width / 2)) - draw.text((symbol_x, start_y), symbol_text, font=symbol_font, fill=symbol_color) - - # Draw price - price_width = int(price_bbox[2] - price_bbox[0]) - price_x = int(column_x - (price_width / 2)) - symbol_height = int(symbol_bbox[3] - symbol_bbox[1]) - price_y = int(start_y + symbol_height + text_gap) # Adjusted gap - draw.text((price_x, price_y), price_text, font=price_font, fill=price_color) - - # Draw change with color based on value (only if change_text is not empty) - if change_text: - change_width = int(change_bbox[2] - change_bbox[0]) - change_x = int(column_x - (change_width / 2)) - price_height = int(price_bbox[3] - price_bbox[1]) - change_y = int(price_y + price_height + text_gap) # Adjusted gap - draw.text((change_x, change_y), change_text, font=change_font, fill=change_color) - - # Draw mini chart on the right only if toggle_chart is enabled - if self.toggle_chart and 'price_history' in data and len(data['price_history']) >= 2: - self._draw_mini_chart(draw, data['price_history'], width, height, change_color) - - return image - - def create_static_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image: - """Create a static display for one stock/crypto (no scrolling).""" - # Ensure dimensions are integers - image = Image.new('RGB', (int(self.display_width), int(self.display_height)), (0, 0, 0)) - draw = ImageDraw.Draw(image) - - is_crypto = data.get('is_crypto', False) - - # Draw logo - logo = self._get_stock_logo(symbol, is_crypto) - if logo: - # Ensure positions are integers - logo_x = 5 - logo_y = int((int(self.display_height) - logo.height) // 2) - image.paste(logo, (int(logo_x), int(logo_y)), logo) - - # Use custom fonts loaded from config - symbol_font = self.symbol_font - price_font = self.price_font - change_font = self.price_delta_font - - # Create text - display_symbol = symbol.replace('-USD', '') if is_crypto else symbol - symbol_text = display_symbol - price_text = f"${data['price']:.2f}" - - # Build change text based on show_change and show_percentage flags - if is_crypto: - show_change = self.config.get('crypto', {}).get('show_change', True) - show_percentage = self.config.get('crypto', {}).get('show_percentage', True) - else: - show_change = self.config.get('show_change', True) - show_percentage = self.config.get('show_percentage', True) - - # Build change text components - change_parts = [] - if show_change: - change_parts.append(f"{data['change']:+.2f}") - if show_percentage: - if 'change_percent' in data: - change_parts.append(f"({data['change_percent']:+.1f}%)") - elif 'open' in data and data['open'] > 0: - change_percent = (data['change'] / data['open']) * 100 - change_parts.append(f"({change_percent:+.1f}%)") - - change_text = " ".join(change_parts) if change_parts else "" - - # Get colors - if data['change'] >= 0: - change_color = self.positive_color if not is_crypto else self.crypto_positive_color - else: - change_color = self.negative_color if not is_crypto else self.crypto_negative_color - - # Use symbol color for symbol, price color for price - symbol_color = self.symbol_text_color if not is_crypto else self.crypto_symbol_text_color - price_color = self.price_text_color if not is_crypto else self.crypto_price_text_color - - # Calculate positions - symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font) - price_bbox = draw.textbbox((0, 0), price_text, font=price_font) - - # Only calculate change_bbox if change_text is not empty - if change_text: - change_bbox = draw.textbbox((0, 0), change_text, font=change_font) - else: - change_bbox = (0, 0, 0, 0) - - # Center everything - ensure integer - center_x = int(self.display_width) // 2 - - # Draw symbol - symbol_width = int(symbol_bbox[2] - symbol_bbox[0]) - symbol_x = int(center_x - (symbol_width / 2)) - draw.text((symbol_x, 5), symbol_text, font=symbol_font, fill=symbol_color) - - # Draw price - price_width = int(price_bbox[2] - price_bbox[0]) - price_x = int(center_x - (price_width / 2)) - draw.text((price_x, 15), price_text, font=price_font, fill=price_color) - - # Draw change (only if change_text is not empty) - if change_text: - change_width = int(change_bbox[2] - change_bbox[0]) - change_x = int(center_x - (change_width / 2)) - draw.text((change_x, 25), change_text, font=change_font, fill=change_color) - - return image - - def create_scrolling_display(self, all_data: Dict[str, Any]) -> Image.Image: - """Create a wide scrolling image with all stocks/crypto - matching old stock_manager spacing.""" - if not all_data: - return self._create_error_display() - - # Calculate total width needed - match old stock_manager spacing logic - # Ensure dimensions are integers - width = int(self.display_width) - height = int(self.display_height) - - # Create individual stock displays - stock_displays = [] - for symbol, data in all_data.items(): - display = self.create_stock_display(symbol, data) - stock_displays.append(display) - - # Calculate spacing - match old stock_manager exactly - # Old code: stock_gap = width // 6, element_gap = width // 8 - stock_gap = int(width // 6) # Reduced gap between stocks - element_gap = int(width // 8) # Reduced gap between elements within a stock - - # Calculate total width - match old stock_manager calculation - # Old code: total_width = sum(width * 2 for _ in symbols) + stock_gap * (len(symbols) - 1) + element_gap * (len(symbols) * 2 - 1) - # But each display already has its own width (width * 2 or width * 1.5), so we sum display widths - # Ensure all values are integers - total_width = sum(int(display.width) for display in stock_displays) - total_width += int(stock_gap) * (len(stock_displays) - 1) - total_width += int(element_gap) * (len(stock_displays) * 2 - 1) - - # Create scrolling image - ensure dimensions are integers - scrolling_image = Image.new('RGB', (int(total_width), int(height)), (0, 0, 0)) - - # Paste all stock displays with spacing - match old stock_manager logic - # Old code: current_x = width (starts with display width gap) - current_x = int(width) # Add initial gap before the first stock - - for i, display in enumerate(stock_displays): - # Paste this stock image into the full image - ensure position is integer tuple - scrolling_image.paste(display, (int(current_x), 0)) - - # Move to next position with consistent spacing - # Old code: current_x += width * 2 + element_gap - current_x += int(display.width) + int(element_gap) - - # Add extra gap between stocks (except after the last stock) - if i < len(stock_displays) - 1: - current_x += int(stock_gap) - - return scrolling_image - - def _get_stock_logo(self, symbol: str, is_crypto: bool = False) -> Optional[Image.Image]: - """Get stock or crypto logo image - matching old stock manager sizing.""" - try: - if is_crypto: - # Try crypto icons first - logo_path = f"assets/stocks/crypto_icons/{symbol}.png" - else: - # Try stock icons - logo_path = f"assets/stocks/ticker_icons/{symbol}.png" - - # Use same sizing as old stock manager (display_width/1.2, display_height/1.2) - max_size = min(int(self.display_width / 1.2), int(self.display_height / 1.2)) - return self.logo_helper.load_logo(symbol, logo_path, max_size, max_size) - - except (OSError, IOError) as e: - self.logger.warning("Error loading logo for %s: %s", symbol, e) - return None - - def _get_stock_color(self, change: float) -> Tuple[int, int, int]: - """Get color based on stock performance - matching old stock manager.""" - if change > 0: - return (0, 255, 0) # Green for positive - elif change < 0: - return (255, 0, 0) # Red for negative - return (255, 255, 0) # Yellow for no change - - def _draw_mini_chart(self, draw: ImageDraw.Draw, price_history: List[Dict], - width: int, height: int, color: Tuple[int, int, int]) -> None: - """Draw a mini price chart on the right side of the display - matching old stock_manager exactly.""" - if len(price_history) < 2: - return - - # Chart dimensions - match old stock_manager exactly - # Old code: chart_width = int(width // 2.5), chart_height = height // 1.5 - # Ensure all dimensions are integers - chart_width = int(width / 2.5) # Reduced from width//2.5 to prevent overlap - chart_height = int(height / 1.5) - chart_x = int(width - chart_width - 4) # 4px margin from right edge - chart_y = int((height - chart_height) / 2) - - # Extract prices - match old stock_manager exactly - prices = [point['price'] for point in price_history if 'price' in point] - if len(prices) < 2: - return - - # Find min and max prices for scaling - match old stock_manager - min_price = min(prices) - max_price = max(prices) - - # Add padding to avoid flat lines when prices are very close - match old stock_manager - price_range = max_price - min_price - if price_range < 0.01: - min_price -= 0.01 - max_price += 0.01 - price_range = 0.02 - - if price_range == 0: - # All prices are the same, draw a horizontal line - y = int(chart_y + chart_height / 2) - draw.line([(chart_x, y), (chart_x + chart_width, y)], fill=color, width=1) - return - - # Calculate points for the line - match old stock_manager exactly - # Ensure all coordinates are integers - points = [] - for i, price in enumerate(prices): - x = int(chart_x + (i * chart_width) / (len(prices) - 1)) - y = int(chart_y + chart_height - int(((price - min_price) / price_range) * chart_height)) - points.append((x, y)) - - # Draw lines between points - match old stock_manager - if len(points) > 1: - for i in range(len(points) - 1): - draw.line([points[i], points[i + 1]], fill=color, width=1) - - def _create_error_display(self) -> Image.Image: - """Create an error display when no data is available.""" - # Ensure dimensions are integers - image = Image.new('RGB', (int(self.display_width), int(self.display_height)), (0, 0, 0)) - draw = ImageDraw.Draw(image) - - # Use symbol font for error display - error_font = self.symbol_font - - # Draw error message - error_text = "No Data Available" - bbox = draw.textbbox((0, 0), error_text, font=error_font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - - # Ensure dimensions are integers - x = (int(self.display_width) - text_width) // 2 - y = (int(self.display_height) - text_height) // 2 - - draw.text((x, y), error_text, font=error_font, fill=(255, 0, 0)) - - return image - - def set_toggle_chart(self, enabled: bool) -> None: - """Set whether to show mini charts.""" - self.toggle_chart = enabled - self.logger.debug("Chart toggle set to: %s", enabled) - - def get_scroll_helper(self) -> ScrollHelper: - """Get the scroll helper instance.""" - return self.scroll_helper diff --git a/plugins/stocks/manager.py b/plugins/stocks/manager.py deleted file mode 100644 index a0300e2e6..000000000 --- a/plugins/stocks/manager.py +++ /dev/null @@ -1,420 +0,0 @@ -""" -Stock & Crypto Ticker Plugin for LEDMatrix (Refactored) - -Displays scrolling stock tickers with prices, changes, and optional charts -for stocks and cryptocurrencies. This refactored version splits functionality -into focused modules for better maintainability. -""" - -import time -from typing import Dict, Any, Optional - -from src.plugin_system.base_plugin import BasePlugin - -# Import our modular components -from data_fetcher import StockDataFetcher -from display_renderer import StockDisplayRenderer -from chart_renderer import StockChartRenderer -from config_manager import StockConfigManager - - -class StockTickerPlugin(BasePlugin): - """ - Stock and cryptocurrency ticker plugin with scrolling display. - - This refactored version uses modular components: - - StockDataFetcher: Handles API calls and data fetching - - StockDisplayRenderer: Handles display creation and layout - - StockChartRenderer: Handles chart drawing functionality - - StockConfigManager: Handles configuration management - """ - - def __init__(self, plugin_id: str, config: Dict[str, Any], - display_manager, cache_manager, plugin_manager): - """Initialize the stock ticker plugin.""" - super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) - - # Get display dimensions - self.display_width = display_manager.width - self.display_height = display_manager.height - - # Initialize modular components - self.config_manager = StockConfigManager(config, self.logger) - self.data_fetcher = StockDataFetcher(self.config_manager, self.cache_manager, self.logger) - self.display_renderer = StockDisplayRenderer( - self.config_manager.plugin_config, - self.display_width, - self.display_height, - self.logger - ) - self.chart_renderer = StockChartRenderer( - self.config_manager.plugin_config, - self.display_width, - self.display_height, - self.logger - ) - - # Plugin state - self.stock_data = {} - self.current_stock_index = 0 - self.scroll_complete = False - self._has_scrolled = False - - # Expose enable_scrolling for display controller FPS detection - self.enable_scrolling = self.config_manager.enable_scrolling - self.last_update_time = 0 - - # Initialize scroll helper - self.scroll_helper = self.display_renderer.get_scroll_helper() - # Convert pixels per frame to pixels per second for ScrollHelper - # scroll_speed is pixels per frame, scroll_delay is seconds per frame - # pixels per second = pixels per frame / seconds per frame - pixels_per_second = self.config_manager.scroll_speed / self.config_manager.scroll_delay if self.config_manager.scroll_delay > 0 else self.config_manager.scroll_speed * 100 - self.scroll_helper.set_scroll_speed(pixels_per_second) - self.scroll_helper.set_scroll_delay(self.config_manager.scroll_delay) - - # Configure dynamic duration settings - self.scroll_helper.set_dynamic_duration_settings( - enabled=self.config_manager.dynamic_duration, - min_duration=int(self.config_manager.min_duration), - max_duration=int(self.config_manager.max_duration), - buffer=self.config_manager.duration_buffer - ) - - self.logger.info("Stock ticker plugin initialized - %dx%d", - self.display_width, self.display_height) - - def update(self) -> None: - """Update stock and crypto data.""" - current_time = time.time() - - # Check if it's time to update - if current_time - self.last_update_time >= self.config_manager.update_interval: - try: - self.logger.debug("Updating stock and crypto data") - fetched_data = self.data_fetcher.fetch_all_data() - self.stock_data = fetched_data - self.last_update_time = current_time - - # Clear scroll cache when data updates - if hasattr(self.scroll_helper, 'cached_image'): - self.scroll_helper.cached_image = None - - - except Exception as e: - import traceback - self.logger.error("Error updating stock/crypto data: %s", e) - self.logger.debug("Traceback: %s", traceback.format_exc()) - - def display(self, force_clear: bool = False) -> None: - """Display stocks with scrolling or static mode.""" - if not self.stock_data: - self.logger.warning("No stock data available, showing error state") - self._show_error_state() - return - - if self.config_manager.enable_scrolling: - self._display_scrolling(force_clear) - else: - self._display_static(force_clear) - - def _display_scrolling(self, force_clear: bool = False) -> None: - """Display stocks with smooth scrolling animation.""" - # Create scrolling image if needed - if not self.scroll_helper.cached_image or force_clear: - self._create_scrolling_display() - - if force_clear: - self.scroll_helper.reset_scroll() - self._has_scrolled = False - self.scroll_complete = False - - # Signal scrolling state - self.display_manager.set_scrolling_state(True) - self.display_manager.process_deferred_updates() - - # Update scroll position using the scroll helper - self.scroll_helper.update_scroll_position() - - # Get visible portion - visible_portion = self.scroll_helper.get_visible_portion() - if visible_portion: - # Update display - paste overwrites previous content (no need to clear) - self.display_manager.image.paste(visible_portion, (0, 0)) - self.display_manager.update_display() - - # Log frame rate (less frequently to avoid spam) - self.scroll_helper.log_frame_rate() - - # Check if scroll is complete using ScrollHelper's method - if hasattr(self.scroll_helper, 'is_scroll_complete'): - self.scroll_complete = self.scroll_helper.is_scroll_complete() - elif self.scroll_helper.scroll_position == 0 and self._has_scrolled: - # Fallback: check if we've wrapped around (position is 0 after scrolling) - self.scroll_complete = True - - def _display_static(self, force_clear: bool = False) -> None: - """Display stocks in static mode - one at a time without scrolling.""" - # Signal not scrolling - self.display_manager.set_scrolling_state(False) - - # Get current stock - symbols = list(self.stock_data.keys()) - if not symbols: - self._show_error_state() - return - - current_symbol = symbols[self.current_stock_index % len(symbols)] - current_data = self.stock_data[current_symbol] - - # Create static display - static_image = self.display_renderer.create_static_display(current_symbol, current_data) - - # Update display - paste overwrites previous content (no need to clear) - self.display_manager.image.paste(static_image, (0, 0)) - self.display_manager.update_display() - - # Move to next stock after a delay - time.sleep(2) # Show each stock for 2 seconds - self.current_stock_index += 1 - - def _create_scrolling_display(self): - """Create the wide scrolling image with all stocks.""" - try: - # Create scrolling image using display renderer - scrolling_image = self.display_renderer.create_scrolling_display(self.stock_data) - - if scrolling_image: - # Set up scroll helper with the image (properly initializes cached_array and state) - self.scroll_helper.set_scrolling_image(scrolling_image) - - self.logger.debug("Created scrolling image: %dx%d", - scrolling_image.width, scrolling_image.height) - else: - self.logger.error("Failed to create scrolling image") - self.scroll_helper.clear_cache() - - except Exception as e: - import traceback - self.logger.error("Error creating scrolling display: %s", e) - self.logger.error("Traceback: %s", traceback.format_exc()) - self.scroll_helper.clear_cache() - - def _show_error_state(self): - """Show error state when no data is available.""" - try: - error_image = self.display_renderer._create_error_display() - self.display_manager.image.paste(error_image, (0, 0)) - self.display_manager.update_display() - except Exception as e: - self.logger.error("Error showing error state: %s", e) - - def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: - """ - Calculate the expected cycle duration based on content width and scroll speed. - - This implements dynamic duration scaling where: - - Duration is calculated from total scroll distance and scroll speed - - Includes buffer time for smooth cycling - - Respects min/max duration limits - - Args: - display_mode: The display mode (unused for stock ticker as it has a single mode) - - Returns: - Calculated duration in seconds, or None if dynamic duration is disabled or not available - """ - # display_mode is unused but kept for API consistency with other plugins - _ = display_mode - if not self.config_manager.dynamic_duration: - return None - - # Check if we have a cached image with calculated duration - if self.scroll_helper and self.scroll_helper.cached_image: - try: - dynamic_duration = self.scroll_helper.get_dynamic_duration() - if dynamic_duration and dynamic_duration > 0: - self.logger.debug( - "get_cycle_duration() returning calculated duration: %.1fs", - dynamic_duration - ) - return float(dynamic_duration) - except Exception as e: - self.logger.warning( - "Error getting dynamic duration from scroll helper: %s", - e - ) - - # If no cached image yet, return None (will be calculated when image is created) - self.logger.debug("get_cycle_duration() returning None (no cached image yet)") - return None - - def get_display_duration(self) -> float: - """ - Get the display duration in seconds. - - If dynamic duration is enabled and scroll helper has calculated a duration, - use that. Otherwise use the static display_duration. - """ - # If dynamic duration is enabled and scroll helper has calculated a duration, use it - if (self.config_manager.dynamic_duration and - hasattr(self.scroll_helper, 'calculated_duration') and - self.scroll_helper.calculated_duration > 0): - return float(self.scroll_helper.calculated_duration) - - # Otherwise use static duration - return self.config_manager.get_display_duration() - - def get_dynamic_duration(self) -> int: - """Get the dynamic duration setting.""" - return self.config_manager.get_dynamic_duration() - - def supports_dynamic_duration(self) -> bool: - """ - Determine whether this plugin should use dynamic display durations. - - Returns True if dynamic_duration is enabled in the display config. - """ - return bool(self.config_manager.dynamic_duration) - - def get_dynamic_duration_cap(self) -> Optional[float]: - """ - Return the maximum duration (in seconds) the controller should wait for - this plugin to complete its display cycle when using dynamic duration. - - Returns the max_duration from config, or None if not set. - """ - if not self.config_manager.dynamic_duration: - return None - - max_duration = self.config_manager.max_duration - if max_duration and max_duration > 0: - return float(max_duration) - return None - - def is_cycle_complete(self) -> bool: - """ - Report whether the plugin has shown a full cycle of content. - - For scrolling content, this checks if the scroll has completed one full cycle. - """ - if not self.config_manager.dynamic_duration: - # If dynamic duration is disabled, always report complete - return True - - if not self.config_manager.enable_scrolling: - # For static mode, cycle is complete after showing all stocks once - if not self.stock_data: - return True - symbols = list(self.stock_data.keys()) - return self.current_stock_index >= len(symbols) - - # For scrolling mode, check if scroll has completed - if hasattr(self.scroll_helper, 'is_scroll_complete'): - return self.scroll_helper.is_scroll_complete() - - # Fallback: check if scroll position has wrapped around - return self.scroll_complete - - def reset_cycle_state(self) -> None: - """ - Reset any internal counters/state related to cycle tracking. - - Called by the display controller before beginning a new dynamic-duration - session. Resets scroll position and stock index. - """ - super().reset_cycle_state() - self.scroll_complete = False - self.current_stock_index = 0 - self._has_scrolled = False - if hasattr(self.scroll_helper, 'reset_scroll'): - self.scroll_helper.reset_scroll() - - def get_info(self) -> Dict[str, Any]: - """Get plugin information.""" - return self.config_manager.get_plugin_info() - - # Configuration methods - def set_toggle_chart(self, enabled: bool) -> None: - """Set whether to show mini charts.""" - self.config_manager.set_toggle_chart(enabled) - self.display_renderer.set_toggle_chart(enabled) - - def set_scroll_speed(self, speed: float) -> None: - """Set the scroll speed (pixels per frame).""" - self.config_manager.set_scroll_speed(speed) - # Convert pixels per frame to pixels per second for ScrollHelper - pixels_per_second = speed / self.config_manager.scroll_delay if self.config_manager.scroll_delay > 0 else speed * 100 - self.scroll_helper.set_scroll_speed(pixels_per_second) - - def set_scroll_delay(self, delay: float) -> None: - """Set the scroll delay.""" - self.config_manager.set_scroll_delay(delay) - # Update scroll helper with new delay and recalculate pixels per second - self.scroll_helper.set_scroll_delay(delay) - # Recalculate pixels per second with new delay - pixels_per_second = self.config_manager.scroll_speed / delay if delay > 0 else self.config_manager.scroll_speed * 100 - self.scroll_helper.set_scroll_speed(pixels_per_second) - - def set_enable_scrolling(self, enabled: bool) -> None: - """Set whether scrolling is enabled.""" - self.config_manager.set_enable_scrolling(enabled) - self.enable_scrolling = enabled # Keep in sync - - def validate_config(self) -> bool: - """Validate plugin configuration.""" - # Call parent validation first - if not super().validate_config(): - return False - - # Use config manager's validation - if not self.config_manager.validate_config(): - return False - - return True - - def on_config_change(self, new_config: Dict[str, Any]) -> None: - """Reload all config-derived attributes when settings change via web UI.""" - super().on_config_change(new_config) - - # Feed new config into config_manager and re-parse all cached attributes - self.config_manager.plugin_config = new_config - self.config_manager._load_config() - - # Sync cached scalar attributes in display_renderer that are read at - # __init__ time and never automatically updated from the config dict. - self.display_renderer.config = new_config - self.display_renderer.toggle_chart = self.config_manager.toggle_chart - - # Sync remaining components - self.chart_renderer.config = new_config - self.data_fetcher.config = self.config_manager.plugin_config - - # Clear scroll cache so the next display() call re-renders with the - # new settings (e.g. chart on/off changes the image dimensions). - self.scroll_helper.clear_cache() - - self.logger.info( - "Stock ticker config reloaded: chart=%s, scroll_speed=%.1f", - self.config_manager.toggle_chart, - self.config_manager.scroll_speed, - ) - - def reload_config(self) -> None: - """Reload configuration.""" - self.config_manager.reload_config() - # Update components with new config - self.data_fetcher.config = self.config_manager.plugin_config - self.display_renderer.config = self.config_manager.plugin_config - # Sync the cached toggle_chart attribute — setting .config alone is not enough - self.display_renderer.toggle_chart = self.config_manager.toggle_chart - self.chart_renderer.config = self.config_manager.plugin_config - - def cleanup(self) -> None: - """Clean up resources.""" - try: - if hasattr(self.data_fetcher, 'cleanup'): - self.data_fetcher.cleanup() - self.logger.info("Stock ticker plugin cleanup completed") - except Exception as e: - self.logger.error("Error during cleanup: %s", e) diff --git a/plugins/weather/manager.py b/plugins/weather/manager.py deleted file mode 100644 index 92165942c..000000000 --- a/plugins/weather/manager.py +++ /dev/null @@ -1,1002 +0,0 @@ -""" -Weather Plugin for LEDMatrix - -Comprehensive weather display with current conditions, hourly forecast, and daily forecast. -Uses OpenWeatherMap API to provide accurate weather information with beautiful icons. - -Features: -- Current weather conditions with temperature, humidity, wind speed -- Hourly forecast (next 24-48 hours) -- Daily forecast (next 7 days) -- Weather icons matching conditions -- UV index display -- Automatic error handling and retry logic - -API Version: 1.0.0 -""" - -import logging -import requests -import time -from datetime import datetime -from typing import Dict, Any, List, Optional -from PIL import Image, ImageDraw -from pathlib import Path - -from src.plugin_system.base_plugin import BasePlugin - -# Import weather icons from local module -try: - # Try relative import first (if module is loaded as package) - from .weather_icons import WeatherIcons -except ImportError: - try: - # Fallback to direct import (plugin dir is in sys.path) - import weather_icons - WeatherIcons = weather_icons.WeatherIcons - except ImportError: - # Fallback if weather icons not available - class WeatherIcons: - @staticmethod - def draw_weather_icon(image, icon_code, x, y, size): - # Simple fallback - just draw a circle - draw = ImageDraw.Draw(image) - draw.ellipse([x, y, x + size, y + size], outline=(255, 255, 255), width=2) - -# Import API counter function -try: - from web_interface_v2 import increment_api_counter -except ImportError: - def increment_api_counter(kind: str, count: int = 1): - pass - -logger = logging.getLogger(__name__) - - -class WeatherPlugin(BasePlugin): - """ - Weather plugin that displays current conditions and forecasts. - - Supports three display modes: - - weather: Current conditions - - hourly_forecast: Hourly forecast for next 48 hours - - daily_forecast: Daily forecast for next 7 days - - Configuration options: - api_key (str): OpenWeatherMap API key - location (dict): City, state, country for weather data - units (str): 'imperial' (F) or 'metric' (C) - update_interval (int): Seconds between API updates - display_modes (dict): Enable/disable specific display modes - """ - - def __init__(self, plugin_id: str, config: Dict[str, Any], - display_manager, cache_manager, plugin_manager): - """Initialize the weather plugin.""" - super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) - - # Weather configuration - self.api_key = config.get('api_key', 'YOUR_OPENWEATHERMAP_API_KEY') - - # Location - read from flat format (location_city, location_state, location_country) - # These are the fields defined in config_schema.json for the web interface - self.location = { - 'city': config.get('location_city', 'Dallas'), - 'state': config.get('location_state', 'Texas'), - 'country': config.get('location_country', 'US') - } - - self.units = config.get('units', 'imperial') - - # Handle update_interval - ensure it's an int - update_interval = config.get('update_interval', 1800) - try: - self.update_interval = int(update_interval) - except (ValueError, TypeError): - self.update_interval = 1800 - - # Display modes - read from flat boolean fields - # These are the fields defined in config_schema.json for the web interface - self.show_current = config.get('show_current_weather', True) - self.show_hourly = config.get('show_hourly_forecast', True) - self.show_daily = config.get('show_daily_forecast', True) - - # Data storage - self.weather_data = None - self.forecast_data = None - self.hourly_forecast = None - self.daily_forecast = None - self.last_update = 0 - - # Error handling and throttling - self.consecutive_errors = 0 - self.last_error_time = 0 - self.error_backoff_time = 60 - self.max_consecutive_errors = 5 - self.error_log_throttle = 300 # Only log errors every 5 minutes - self.last_error_log_time = 0 - self._last_error_hint = None # Human-readable hint for diagnostic display - - # State caching for display optimization - self.last_weather_state = None - self.last_hourly_state = None - self.last_daily_state = None - self.current_display_mode = None # Track current mode to detect switches - - # Internal mode cycling (similar to hockey plugin) - # Build list of enabled modes in order - self.modes = [] - if self.show_current: - self.modes.append('weather') - if self.show_hourly: - self.modes.append('hourly_forecast') - if self.show_daily: - self.modes.append('daily_forecast') - - # Default to first mode if none enabled - if not self.modes: - self.modes = ['weather'] - - self.current_mode_index = 0 - self.last_mode_switch = 0 - self.display_duration = config.get('display_duration', 30) - - # Layout constants - self.PADDING = 1 - self.COLORS = { - 'text': (255, 255, 255), - 'highlight': (255, 200, 0), - 'separator': (64, 64, 64), - 'temp_high': (255, 100, 100), - 'temp_low': (100, 100, 255), - 'dim': (180, 180, 180), - 'extra_dim': (120, 120, 120), - 'uv_low': (0, 150, 0), - 'uv_moderate': (255, 200, 0), - 'uv_high': (255, 120, 0), - 'uv_very_high': (200, 0, 0), - 'uv_extreme': (150, 0, 200) - } - - # Resolve project root path (plugin_dir -> plugins -> project_root) - self.project_root = Path(__file__).resolve().parent.parent.parent - - # Weather icons path (Note: WeatherIcons class resolves paths itself, this is just for reference) - self.icons_dir = self.project_root / 'assets' / 'weather' - - # Register fonts - self._register_fonts() - - self.logger.info(f"Weather plugin initialized for {self.location.get('city', 'Unknown')}") - self.logger.info(f"Units: {self.units}, Update interval: {self.update_interval}s") - - def _register_fonts(self): - """Register fonts with the font manager.""" - try: - if not hasattr(self.plugin_manager, 'font_manager') or self.plugin_manager.font_manager is None: - self.logger.warning("Font manager not available") - return - - font_manager = self.plugin_manager.font_manager - - # Register fonts for different elements - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.temperature", - family="press_start", - size_px=16, - color=self.COLORS['text'] - ) - - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.condition", - family="four_by_six", - size_px=8, - color=self.COLORS['highlight'] - ) - - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.forecast_label", - family="four_by_six", - size_px=6, - color=self.COLORS['dim'] - ) - - self.logger.info("Weather plugin fonts registered successfully") - except Exception as e: - self.logger.warning(f"Error registering fonts: {e}") - - def _get_layout(self) -> dict: - """Return cached layout parameters (computed once on first call). - - Icon sizes scale proportionally with display height. - Text spacing stays fixed because fonts are fixed-size bitmaps. - Reference baseline: 128x32 display. - """ - if hasattr(self, '_layout_cache'): - return self._layout_cache - - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - h_scale = height / 32.0 - - # Fixed font metrics (do not change with display size) - small_font_h = 8 - extra_small_font_h = 7 - - margin = max(1, round(1 * h_scale)) - - # --- Current weather mode --- - current_icon_size = max(14, round(40 * h_scale)) - current_icon_x = margin - current_available_h = (height * 2) // 3 - current_icon_y = (current_available_h - current_icon_size) // 2 - - # Text rows on right side (fixed spacing since fonts are fixed) - condition_y = margin - temp_y = condition_y + small_font_h - high_low_y = temp_y + small_font_h - bottom_bar_y = height - extra_small_font_h - - # --- Forecast modes (hourly + daily) --- - # Scale with height but cap by narrowest column width to prevent overflow - min_column_width = width // 4 - forecast_icon_size = max(14, min(round(30 * h_scale), min_column_width)) - forecast_top_y = margin - forecast_icon_y = max(0, (height - forecast_icon_size) // 2) - forecast_bottom_y = height - small_font_h - - self._layout_cache = { - 'current_icon_size': current_icon_size, - 'current_icon_x': current_icon_x, - 'current_icon_y': current_icon_y, - 'condition_y': condition_y, - 'temp_y': temp_y, - 'high_low_y': high_low_y, - 'bottom_bar_y': bottom_bar_y, - 'right_margin': margin, - 'forecast_icon_size': forecast_icon_size, - 'forecast_top_y': forecast_top_y, - 'forecast_icon_y': forecast_icon_y, - 'forecast_bottom_y': forecast_bottom_y, - 'margin': margin, - } - return self._layout_cache - - def update(self) -> None: - """ - Update weather data from OpenWeatherMap API. - - Fetches current conditions and forecast data, respecting - update intervals and error backoff periods. - """ - current_time = time.time() - - # Check if we need to update - if current_time - self.last_update < self.update_interval: - return - - # Check if we're in error backoff period - if self.consecutive_errors >= self.max_consecutive_errors: - if current_time - self.last_error_time < self.error_backoff_time: - self.logger.debug(f"In error backoff period, retrying in {self.error_backoff_time - (current_time - self.last_error_time):.0f}s") - return - else: - # Reset error count after backoff - self.consecutive_errors = 0 - self.error_backoff_time = 60 - - # Validate API key - if not self.api_key or self.api_key == "YOUR_OPENWEATHERMAP_API_KEY": - self.logger.warning("No valid OpenWeatherMap API key configured") - return - - # Try to fetch weather data - try: - self._fetch_weather() - self.last_update = current_time - self.consecutive_errors = 0 - self._last_error_hint = None - except Exception as e: - self.consecutive_errors += 1 - self.last_error_time = current_time - if not self._last_error_hint: - self._last_error_hint = str(e)[:40] - - # Exponential backoff: double the backoff time (max 1 hour) - self.error_backoff_time = min(self.error_backoff_time * 2, 3600) - - # Only log errors periodically to avoid spam - if current_time - self.last_error_log_time > self.error_log_throttle: - self.logger.error(f"Error updating weather (attempt {self.consecutive_errors}/{self.max_consecutive_errors}): {e}") - if self.consecutive_errors >= self.max_consecutive_errors: - self.logger.error(f"Weather API disabled for {self.error_backoff_time} seconds due to repeated failures") - self.last_error_log_time = current_time - - def _fetch_weather(self) -> None: - """Fetch weather data from OpenWeatherMap API.""" - # Check cache first - use update_interval as max_age to respect configured refresh rate - cache_key = 'weather' - cached_data = self.cache_manager.get(cache_key, max_age=self.update_interval) - if cached_data: - self.weather_data = cached_data.get('current') - self.forecast_data = cached_data.get('forecast') - if self.weather_data and self.forecast_data: - self._process_forecast_data(self.forecast_data) - self.logger.info("Using cached weather data") - return - - # Fetch fresh data - city = self.location.get('city', 'Dallas') - state = self.location.get('state', 'Texas') - country = self.location.get('country', 'US') - - # Get coordinates using geocoding API - geo_url = f"http://api.openweathermap.org/geo/1.0/direct?q={city},{state},{country}&limit=1&appid={self.api_key}" - - try: - response = requests.get(geo_url, timeout=10) - response.raise_for_status() - except requests.exceptions.HTTPError as e: - status = e.response.status_code if e.response is not None else None - if status == 401: - self._last_error_hint = "Invalid API key" - self.logger.error( - "Geocoding API returned 401 Unauthorized. " - "Verify your API key is correct at https://openweathermap.org/api" - ) - elif status == 429: - self._last_error_hint = "Rate limit exceeded" - self.logger.error("Geocoding API rate limit exceeded (429). Increase update_interval.") - else: - self._last_error_hint = f"Geo API error {status}" - self.logger.error(f"Geocoding API HTTP error {status}: {e}") - raise - geo_data = response.json() - - # Increment API counter for geocoding call - increment_api_counter('weather', 1) - - if not geo_data: - self._last_error_hint = f"Unknown: {city}, {state}" - self.logger.error(f"Could not find coordinates for {city}, {state}, {country}") - self.last_update = time.time() # Prevent immediate retry - return - - lat = geo_data[0]['lat'] - lon = geo_data[0]['lon'] - - # Get weather data using One Call API - one_call_url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&exclude=minutely,alerts&appid={self.api_key}&units={self.units}" - - try: - response = requests.get(one_call_url, timeout=10) - response.raise_for_status() - except requests.exceptions.HTTPError as e: - status = e.response.status_code if e.response is not None else None - if status == 401: - self._last_error_hint = "Subscribe to One Call 3.0" - self.logger.error( - "One Call API 3.0 returned 401 Unauthorized. " - "Your API key is NOT subscribed to One Call API 3.0. " - "Subscribe (free tier available) at https://openweathermap.org/api " - "-> One Call API 3.0 -> Subscribe." - ) - elif status == 429: - self._last_error_hint = "Rate limit exceeded" - self.logger.error("One Call API rate limit exceeded (429). Increase update_interval.") - else: - self._last_error_hint = f"Weather API error {status}" - self.logger.error(f"One Call API HTTP error {status}: {e}") - raise - one_call_data = response.json() - - # Increment API counter for weather data call - increment_api_counter('weather', 1) - - # Store current weather data - self.weather_data = { - 'main': { - 'temp': one_call_data['current']['temp'], - 'temp_max': one_call_data['daily'][0]['temp']['max'], - 'temp_min': one_call_data['daily'][0]['temp']['min'], - 'humidity': one_call_data['current']['humidity'], - 'pressure': one_call_data['current']['pressure'], - 'uvi': one_call_data['current'].get('uvi', 0) - }, - 'weather': one_call_data['current']['weather'], - 'wind': { - 'speed': one_call_data['current']['wind_speed'], - 'deg': one_call_data['current'].get('wind_deg', 0) - } - } - - # Store forecast data - self.forecast_data = one_call_data - - # Process forecast data - self._process_forecast_data(self.forecast_data) - - # Cache the data - self.cache_manager.set(cache_key, { - 'current': self.weather_data, - 'forecast': self.forecast_data - }) - - self.logger.info(f"Weather data updated for {city}: {self.weather_data['main']['temp']}°") - - def _process_forecast_data(self, forecast_data: Dict) -> None: - """Process forecast data into hourly and daily lists.""" - if not forecast_data: - return - - # Process hourly forecast (next 5 hours, excluding current hour) - hourly_list = forecast_data.get('hourly', []) - - # Filter out the current hour - get current timestamp rounded down to the hour - current_time = time.time() - current_hour_timestamp = int(current_time // 3600) * 3600 # Round down to nearest hour - - # Filter out entries that are in the current hour or past - future_hourly = [ - hour_data for hour_data in hourly_list - if hour_data.get('dt', 0) > current_hour_timestamp - ] - - # Get next 5 hours - hourly_list = future_hourly[:5] - self.hourly_forecast = [] - - for hour_data in hourly_list: - dt = datetime.fromtimestamp(hour_data['dt']) - temp = round(hour_data['temp']) - condition = hour_data['weather'][0]['main'] - icon_code = hour_data['weather'][0]['icon'] - self.hourly_forecast.append({ - 'hour': dt.strftime('%I:00 %p').lstrip('0'), # Format as "2:00 PM" - 'temp': temp, - 'condition': condition, - 'icon': icon_code - }) - - # Process daily forecast - daily_list = forecast_data.get('daily', [])[1:4] # Skip today (index 0) and get next 3 days - self.daily_forecast = [] - - for day_data in daily_list: - dt = datetime.fromtimestamp(day_data['dt']) - temp_high = round(day_data['temp']['max']) - temp_low = round(day_data['temp']['min']) - condition = day_data['weather'][0]['main'] - icon_code = day_data['weather'][0]['icon'] - - self.daily_forecast.append({ - 'date': dt.strftime('%a'), # Day name (Mon, Tue, etc.) - 'date_str': dt.strftime('%m/%d'), # Date (4/8, 4/9, etc.) - 'temp_high': temp_high, - 'temp_low': temp_low, - 'condition': condition, - 'icon': icon_code - }) - - def display(self, display_mode: str = None, force_clear: bool = False) -> None: - """ - Display weather information with internal mode cycling. - - The display controller registers each mode separately (weather, hourly_forecast, daily_forecast) - but calls display() without passing the mode name. This plugin handles mode cycling internally - similar to the hockey plugin, advancing through enabled modes based on time. - - Args: - display_mode: Optional mode name (not currently used, kept for compatibility) - force_clear: If True, clear the display before rendering (ignored, kept for compatibility) - """ - if not self.weather_data: - self._display_no_data() - return - - # Note: force_clear is handled by display_manager, not needed here - # This parameter is kept for compatibility with BasePlugin interface - - current_mode = None - - # If a specific mode is requested (compatibility methods), honor it - if display_mode and display_mode in self.modes: - try: - requested_index = self.modes.index(display_mode) - except ValueError: - requested_index = None - - if requested_index is not None: - current_mode = self.modes[requested_index] - if current_mode != self.current_display_mode: - self.current_mode_index = requested_index - self._on_mode_changed(current_mode) - else: - # Default rotation synchronized with display controller - if self.current_display_mode is None: - current_mode = self.modes[self.current_mode_index] - self._on_mode_changed(current_mode) - elif force_clear: - self.current_mode_index = (self.current_mode_index + 1) % len(self.modes) - current_mode = self.modes[self.current_mode_index] - self._on_mode_changed(current_mode) - else: - current_mode = self.modes[self.current_mode_index] - - # Ensure we have a mode even if none of the above paths triggered a change - if current_mode is None: - current_mode = self.current_display_mode or self.modes[self.current_mode_index] - - # Display the current mode - if current_mode == 'hourly_forecast' and self.show_hourly: - self._display_hourly_forecast() - elif current_mode == 'daily_forecast' and self.show_daily: - self._display_daily_forecast() - elif current_mode == 'weather' and self.show_current: - self._display_current_weather() - else: - # Fallback: show current weather if mode doesn't match - self.logger.warning(f"Mode {current_mode} not available, showing current weather") - self._display_current_weather() - - def _on_mode_changed(self, new_mode: str) -> None: - """Handle logic needed when switching display modes.""" - if new_mode == self.current_display_mode: - return - - self.logger.info(f"Display mode changed from {self.current_display_mode} to {new_mode}") - if new_mode == 'hourly_forecast': - self.last_hourly_state = None - self.logger.debug("Reset hourly state cache for mode switch") - elif new_mode == 'daily_forecast': - self.last_daily_state = None - self.logger.debug("Reset daily state cache for mode switch") - else: - self.last_weather_state = None - self.logger.debug("Reset weather state cache for mode switch") - - self.current_display_mode = new_mode - self.last_mode_switch = time.time() - - def _display_no_data(self) -> None: - """Display a diagnostic message when no weather data is available.""" - img = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - - from PIL import ImageFont - try: - font_path = self.project_root / 'assets' / 'fonts' / '4x6-font.ttf' - font = ImageFont.truetype(str(font_path), 8) - except Exception: - font = ImageFont.load_default() - - if not self.api_key or self.api_key == "YOUR_OPENWEATHERMAP_API_KEY": - draw.text((2, 8), "Weather:", font=font, fill=(200, 200, 200)) - draw.text((2, 18), "No API Key", font=font, fill=(255, 100, 100)) - elif self._last_error_hint: - draw.text((2, 4), "Weather Err", font=font, fill=(200, 200, 200)) - hint = self._last_error_hint[:22] - draw.text((2, 14), hint, font=font, fill=(255, 100, 100)) - else: - draw.text((5, 8), "No Weather", font=font, fill=(200, 200, 200)) - draw.text((5, 18), "Data", font=font, fill=(200, 200, 200)) - - self.display_manager.image = img - self.display_manager.update_display() - - def _render_current_weather_image(self) -> Optional[Image.Image]: - """Render current weather conditions to an Image without display side effects.""" - try: - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - img = Image.new('RGB', (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - - # Get weather info - temp = int(self.weather_data['main']['temp']) - condition = self.weather_data['weather'][0]['main'] - icon_code = self.weather_data['weather'][0]['icon'] - humidity = self.weather_data['main']['humidity'] - wind_speed = self.weather_data['wind'].get('speed', 0) - wind_deg = self.weather_data['wind'].get('deg', 0) - uv_index = self.weather_data['main'].get('uvi', 0) - temp_high = int(self.weather_data['main']['temp_max']) - temp_low = int(self.weather_data['main']['temp_min']) - - layout = self._get_layout() - - # --- Top Left: Weather Icon --- - icon_size = layout['current_icon_size'] - icon_x = layout['current_icon_x'] - icon_y = layout['current_icon_y'] - WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y, size=icon_size) - - # --- Top Right: Condition Text --- - condition_font = self.display_manager.small_font - condition_text_width = draw.textlength(condition, font=condition_font) - condition_x = width - condition_text_width - layout['right_margin'] - condition_y = layout['condition_y'] - draw.text((condition_x, condition_y), condition, font=condition_font, fill=self.COLORS['text']) - - # --- Right Side: Current Temperature --- - temp_text = f"{temp}°" - temp_font = self.display_manager.small_font - temp_text_width = draw.textlength(temp_text, font=temp_font) - temp_x = width - temp_text_width - layout['right_margin'] - temp_y = layout['temp_y'] - draw.text((temp_x, temp_y), temp_text, font=temp_font, fill=self.COLORS['highlight']) - - # --- Right Side: High/Low Temperature --- - high_low_text = f"{temp_low}°/{temp_high}°" - high_low_font = self.display_manager.small_font - high_low_width = draw.textlength(high_low_text, font=high_low_font) - high_low_x = width - high_low_width - layout['right_margin'] - high_low_y = layout['high_low_y'] - draw.text((high_low_x, high_low_y), high_low_text, font=high_low_font, fill=self.COLORS['dim']) - - # --- Bottom: Additional Metrics --- - section_width = width // 3 - y_pos = layout['bottom_bar_y'] - font = self.display_manager.extra_small_font - - # UV Index (Section 1) - uv_prefix = "UV:" - uv_value_text = f"{uv_index:.0f}" - prefix_width = draw.textlength(uv_prefix, font=font) - value_width = draw.textlength(uv_value_text, font=font) - total_width = prefix_width + value_width - start_x = (section_width - total_width) // 2 - draw.text((start_x, y_pos), uv_prefix, font=font, fill=self.COLORS['dim']) - uv_color = self._get_uv_color(uv_index) - draw.text((start_x + prefix_width, y_pos), uv_value_text, font=font, fill=uv_color) - - # Humidity (Section 2) - humidity_text = f"H:{humidity}%" - humidity_width = draw.textlength(humidity_text, font=font) - humidity_x = section_width + (section_width - humidity_width) // 2 - draw.text((humidity_x, y_pos), humidity_text, font=font, fill=self.COLORS['dim']) - - # Wind (Section 3) - wind_dir = self._get_wind_direction(wind_deg) - wind_text = f"W:{wind_speed:.0f}{wind_dir}" - wind_width = draw.textlength(wind_text, font=font) - wind_x = (2 * section_width) + (section_width - wind_width) // 2 - draw.text((wind_x, y_pos), wind_text, font=font, fill=self.COLORS['dim']) - - return img - except Exception as e: - self.logger.exception("Error rendering current weather") - return None - - def _display_current_weather(self) -> None: - """Display current weather conditions using comprehensive layout with icons.""" - try: - current_state = self._get_weather_state() - if current_state == self.last_weather_state: - self.display_manager.update_display() - return - - self.display_manager.clear() - img = self._render_current_weather_image() - if img: - self.display_manager.image = img - self.display_manager.update_display() - self.last_weather_state = current_state - except Exception as e: - self.logger.error(f"Error displaying current weather: {e}") - - def _get_wind_direction(self, degrees: float) -> str: - """Convert wind degrees to cardinal direction.""" - directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] - index = round(degrees / 45) % 8 - return directions[index] - - def _get_uv_color(self, uv_index: float) -> tuple: - """Get color based on UV index value.""" - if uv_index <= 2: - return self.COLORS['uv_low'] - elif uv_index <= 5: - return self.COLORS['uv_moderate'] - elif uv_index <= 7: - return self.COLORS['uv_high'] - elif uv_index <= 10: - return self.COLORS['uv_very_high'] - else: - return self.COLORS['uv_extreme'] - - def _get_weather_state(self) -> Dict[str, Any]: - """Get current weather state for comparison.""" - if not self.weather_data: - return None - return { - 'temp': round(self.weather_data['main']['temp']), - 'condition': self.weather_data['weather'][0]['main'], - 'humidity': self.weather_data['main']['humidity'], - 'uvi': self.weather_data['main'].get('uvi', 0) - } - - def _get_hourly_state(self) -> List[Dict[str, Any]]: - """Get current hourly forecast state for comparison.""" - if not self.hourly_forecast: - return None - return [ - {'hour': f['hour'], 'temp': round(f['temp']), 'condition': f['condition']} - for f in self.hourly_forecast[:3] - ] - - def _get_daily_state(self) -> List[Dict[str, Any]]: - """Get current daily forecast state for comparison.""" - if not self.daily_forecast: - return None - return [ - { - 'date': f['date'], - 'temp_high': round(f['temp_high']), - 'temp_low': round(f['temp_low']), - 'condition': f['condition'] - } - for f in self.daily_forecast[:4] - ] - - def _render_hourly_forecast_image(self) -> Optional[Image.Image]: - """Render hourly forecast to an Image without display side effects.""" - try: - if not self.hourly_forecast: - return None - - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - img = Image.new('RGB', (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - - layout = self._get_layout() - hours_to_show = min(4, len(self.hourly_forecast)) - section_width = width // hours_to_show - padding = max(2, section_width // 6) - - for i in range(hours_to_show): - forecast = self.hourly_forecast[i] - x = i * section_width + padding - center_x = x + (section_width - 2 * padding) // 2 - - # Hour at top - hour_text = forecast['hour'] - hour_text = hour_text.replace(":00 ", "").replace("PM", "p").replace("AM", "a") - hour_width = draw.textlength(hour_text, font=self.display_manager.small_font) - draw.text((center_x - hour_width // 2, layout['forecast_top_y']), - hour_text, - font=self.display_manager.small_font, - fill=self.COLORS['text']) - - # Weather icon - icon_size = layout['forecast_icon_size'] - icon_y = layout['forecast_icon_y'] - icon_x = center_x - icon_size // 2 - WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size) - - # Temperature at bottom - temp_text = f"{forecast['temp']}°" - temp_width = draw.textlength(temp_text, font=self.display_manager.small_font) - temp_y = layout['forecast_bottom_y'] - draw.text((center_x - temp_width // 2, temp_y), - temp_text, - font=self.display_manager.small_font, - fill=self.COLORS['text']) - - return img - except Exception as e: - self.logger.exception("Error rendering hourly forecast") - return None - - def _display_hourly_forecast(self) -> None: - """Display hourly forecast with weather icons.""" - try: - if not self.hourly_forecast: - self.logger.warning("No hourly forecast data available, showing no data message") - self._display_no_data() - return - - current_state = self._get_hourly_state() - if current_state == self.last_hourly_state: - self.display_manager.update_display() - return - - self.display_manager.clear() - img = self._render_hourly_forecast_image() - if img: - self.display_manager.image = img - self.display_manager.update_display() - self.last_hourly_state = current_state - except Exception as e: - self.logger.error(f"Error displaying hourly forecast: {e}") - - def _render_daily_forecast_image(self) -> Optional[Image.Image]: - """Render daily forecast to an Image without display side effects.""" - try: - if not self.daily_forecast: - return None - - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - img = Image.new('RGB', (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - - layout = self._get_layout() - days_to_show = min(3, len(self.daily_forecast)) - if days_to_show == 0: - draw.text((2, 2), "No daily forecast", font=self.display_manager.small_font, fill=self.COLORS['dim']) - else: - section_width = width // days_to_show - - for i in range(days_to_show): - forecast = self.daily_forecast[i] - center_x = i * section_width + section_width // 2 - - # Day name at top - day_text = forecast['date'] - day_width = draw.textlength(day_text, font=self.display_manager.small_font) - draw.text((center_x - day_width // 2, layout['forecast_top_y']), - day_text, - font=self.display_manager.small_font, - fill=self.COLORS['text']) - - # Weather icon - icon_size = layout['forecast_icon_size'] - icon_y = layout['forecast_icon_y'] - icon_x = center_x - icon_size // 2 - WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size) - - # High/low temperatures at bottom - temp_text = f"{forecast['temp_low']} / {forecast['temp_high']}" - temp_width = draw.textlength(temp_text, font=self.display_manager.extra_small_font) - temp_y = layout['forecast_bottom_y'] - draw.text((center_x - temp_width // 2, temp_y), - temp_text, - font=self.display_manager.extra_small_font, - fill=self.COLORS['text']) - - return img - except Exception as e: - self.logger.exception("Error rendering daily forecast") - return None - - def _display_daily_forecast(self) -> None: - """Display daily forecast with weather icons.""" - try: - if not self.daily_forecast: - self._display_no_data() - return - - current_state = self._get_daily_state() - if current_state == self.last_daily_state: - self.display_manager.update_display() - return - - self.display_manager.clear() - img = self._render_daily_forecast_image() - if img: - self.display_manager.image = img - self.display_manager.update_display() - self.last_daily_state = current_state - except Exception as e: - self.logger.error(f"Error displaying daily forecast: {e}") - - def get_vegas_content(self): - """Return images for all enabled weather display modes.""" - if not self.weather_data: - return None - - images = [] - - if self.show_current: - img = self._render_current_weather_image() - if img: - images.append(img) - - if self.show_hourly and self.hourly_forecast: - img = self._render_hourly_forecast_image() - if img: - images.append(img) - - if self.show_daily and self.daily_forecast: - img = self._render_daily_forecast_image() - if img: - images.append(img) - - if images: - total_width = sum(img.width for img in images) - self.logger.info( - "[Weather Vegas] Returning %d image(s), %dpx total", - len(images), total_width - ) - return images - - return None - - def display_weather(self, force_clear: bool = False) -> None: - """Display current weather (compatibility method for display controller).""" - self.display('weather', force_clear) - - def display_hourly_forecast(self, force_clear: bool = False) -> None: - """Display hourly forecast (compatibility method for display controller).""" - self.display('hourly_forecast', force_clear) - - def display_daily_forecast(self, force_clear: bool = False) -> None: - """Display daily forecast (compatibility method for display controller).""" - self.display('daily_forecast', force_clear) - - def get_info(self) -> Dict[str, Any]: - """Return plugin info for web UI.""" - info = super().get_info() - info.update({ - 'location': self.location, - 'units': self.units, - 'api_key_configured': bool(self.api_key), - 'last_update': self.last_update, - 'current_temp': self.weather_data.get('main', {}).get('temp') if self.weather_data else None, - 'current_humidity': self.weather_data.get('main', {}).get('humidity') if self.weather_data else None, - 'current_description': self.weather_data.get('weather', [{}])[0].get('description', '') if self.weather_data else '', - 'forecast_available': bool(self.forecast_data), - 'daily_forecast_count': len(self.daily_forecast) if hasattr(self, 'daily_forecast') and self.daily_forecast is not None else 0, - 'hourly_forecast_count': len(self.hourly_forecast) if hasattr(self, 'hourly_forecast') and self.hourly_forecast is not None else 0 - }) - return info - - def on_config_change(self, new_config: Dict[str, Any]) -> None: - """Reload all config-derived attributes when settings change via web UI.""" - super().on_config_change(new_config) - - self.api_key = new_config.get('api_key', 'YOUR_OPENWEATHERMAP_API_KEY') - self.location = { - 'city': new_config.get('location_city', 'Dallas'), - 'state': new_config.get('location_state', 'Texas'), - 'country': new_config.get('location_country', 'US') - } - self.units = new_config.get('units', 'imperial') - update_interval = new_config.get('update_interval', 1800) - try: - self.update_interval = int(update_interval) - except (ValueError, TypeError): - self.update_interval = 1800 - - self.show_current = new_config.get('show_current_weather', True) - self.show_hourly = new_config.get('show_hourly_forecast', True) - self.show_daily = new_config.get('show_daily_forecast', True) - self.display_duration = new_config.get('display_duration', 30) - - # Rebuild the enabled modes list - self.modes = [] - if self.show_current: - self.modes.append('weather') - if self.show_hourly: - self.modes.append('hourly_forecast') - if self.show_daily: - self.modes.append('daily_forecast') - if not self.modes: - self.modes = ['weather'] - - # Reset update timer and error state so new settings take effect immediately - self.last_update = 0 - self.consecutive_errors = 0 - self.error_backoff_time = 60 - - # Clear layout cache since units/display settings may have changed - if hasattr(self, '_layout_cache'): - del self._layout_cache - - self.logger.info( - "Weather plugin config reloaded: city=%s, units=%s, api_key_set=%s", - self.location.get('city'), - self.units, - bool(self.api_key and self.api_key != 'YOUR_OPENWEATHERMAP_API_KEY') - ) - - def cleanup(self) -> None: - """Cleanup resources.""" - self.weather_data = None - self.forecast_data = None - self.logger.info("Weather plugin cleaned up") - From 3bdc398b6da6fd056e585305860596b0fe3ee2a1 Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:18:48 -0500 Subject: [PATCH 10/14] fix(security): address CodeRabbit security findings - toggle_category.py: validate category_name against allowlist (letters/ numbers/_/-) to prevent path traversal in data_file construction; fix bool() mis-parsing of string inputs ("false" now correctly parsed); narrow broad except to (OSError, TypeError) - upload_file.py: add path traversal check (../ / \) after .json check - save_file.py: add mkdir(exist_ok=True) for data_dir; add .json extension enforcement before path traversal check - file_manager.html: add escapeJsString() helper; apply to all 4 inline onclick/onchange interpolations to prevent XSS via filenames - api_v3.py: resolve script_file and validate it is confined to plugin_dir using relative_to(); replace all 'python3' subprocess calls with sys.executable to avoid PATH surprises - pages_v3.py: fail closed on secret masking errors (raise instead of silent pass) to prevent leaking secrets on unexpected failures Co-Authored-By: Claude Sonnet 4.6 --- plugin-repos/of-the-day/scripts/save_file.py | 10 +++++++- .../of-the-day/scripts/toggle_category.py | 24 ++++++++++++++++--- .../of-the-day/scripts/upload_file.py | 9 ++++++- .../of-the-day/web_ui/file_manager.html | 16 +++++++++---- web_interface/blueprints/api_v3.py | 18 +++++++++----- web_interface/blueprints/pages_v3.py | 2 +- 6 files changed, 63 insertions(+), 16 deletions(-) diff --git a/plugin-repos/of-the-day/scripts/save_file.py b/plugin-repos/of-the-day/scripts/save_file.py index 63f9dcb2a..eb6476ff1 100644 --- a/plugin-repos/of-the-day/scripts/save_file.py +++ b/plugin-repos/of-the-day/scripts/save_file.py @@ -12,6 +12,7 @@ # 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) @@ -32,7 +33,14 @@ '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) diff --git a/plugin-repos/of-the-day/scripts/toggle_category.py b/plugin-repos/of-the-day/scripts/toggle_category.py index 0c5f8bc93..8917c619e 100644 --- a/plugin-repos/of-the-day/scripts/toggle_category.py +++ b/plugin-repos/of-the-day/scripts/toggle_category.py @@ -5,6 +5,7 @@ """ import os +import re import json import sys from pathlib import Path @@ -34,6 +35,13 @@ })) 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: @@ -70,8 +78,18 @@ # Determine new enabled state if 'enabled' in params: - # Explicit state provided - new_enabled = bool(params['enabled']) + # 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) @@ -87,7 +105,7 @@ 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 Exception as e: +except (OSError, TypeError) as e: print(json.dumps({ 'status': 'error', 'message': f'Failed to save config: {str(e)}' diff --git a/plugin-repos/of-the-day/scripts/upload_file.py b/plugin-repos/of-the-day/scripts/upload_file.py index ac13f113c..6912184e3 100644 --- a/plugin-repos/of-the-day/scripts/upload_file.py +++ b/plugin-repos/of-the-day/scripts/upload_file.py @@ -34,7 +34,14 @@ '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) diff --git a/plugin-repos/of-the-day/web_ui/file_manager.html b/plugin-repos/of-the-day/web_ui/file_manager.html index e8b46e5e2..51a7d7363 100644 --- a/plugin-repos/of-the-day/web_ui/file_manager.html +++ b/plugin-repos/of-the-day/web_ui/file_manager.html @@ -545,12 +545,12 @@

Delete File

container.innerHTML = `
${files.map(file => ` -
+
${file.enabled !== false ? 'Enabled' : 'Disabled'}
@@ -565,11 +565,11 @@

Delete File

${formatDate(file.modified || '')}
- -
@@ -983,6 +983,14 @@

Delete File

return div.innerHTML; } + function escapeJsString(text) { + return String(text) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); + } + function showSuccess(message) { if (typeof showNotification === 'function') { showNotification(message, 'success'); diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index ed6fa8984..2e621006a 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -5139,7 +5139,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 @@ -5181,7 +5187,7 @@ def execute_plugin_action(): try: result = subprocess.run( - ['python3', wrapper_path], + [sys.executable, wrapper_path], capture_output=True, text=True, timeout=120, @@ -5214,7 +5220,7 @@ def execute_plugin_action(): try: result = subprocess.run( - ['python3', script_file], + [sys.executable, str(script_file)], input=params_stdin, capture_output=True, text=True, @@ -5313,7 +5319,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, @@ -5424,7 +5430,7 @@ def authenticate_spotify(): try: result = subprocess.run( - ['python3', wrapper_path], + [sys.executable, wrapper_path], capture_output=True, text=True, timeout=120, @@ -5531,7 +5537,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 c8a8c6e15..2a8e90366 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -425,7 +425,7 @@ def _mask_secrets(config_dict, secrets_set, prefix=''): if secret_fields: config = _mask_secrets(config, secret_fields) except Exception: - pass # Best effort — don't fail the render if masking errors + raise # Fail closed — do not silently leak secrets # Get web UI actions from plugin manifest web_ui_actions = [] From 2e8ebf025c24b76e2c3b14214d73232fe3854b75 Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:20:27 -0500 Subject: [PATCH 11/14] fix(bugs): address CodeRabbit major/minor bug findings of-the-day/manager.py: - Add _get_positive_float() helper to coerce and validate interval config values; use it for update_interval, display_rotate_interval, subtitle_rotate_interval in __init__ and on_config_change to prevent TypeError/ValueError from bad user config - Replace 2 bare except: blocks in _display_content font loading with except OSError and add self.logger.warning() for diagnostics - Add return type annotation to _register_fonts (-> None) web_interface/blueprints/api_v3.py: - Treat whitespace-only strings as empty in _drop_empty_secrets and _filter_empty_secrets (v.strip() == '' check) so " " doesn't overwrite stored secrets - Add live_priority: False to default plugin config returned when no config exists, preventing partial configs on first save - Update _mask_values to mask non-string non-empty values (numeric tokens etc.) not just non-empty non-placeholder strings Co-Authored-By: Claude Sonnet 4.6 --- plugin-repos/of-the-day/manager.py | 35 +++++++++++++++++++++--------- web_interface/blueprints/api_v3.py | 9 ++++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/plugin-repos/of-the-day/manager.py b/plugin-repos/of-the-day/manager.py index c85f3c46f..95ec81c85 100644 --- a/plugin-repos/of-the-day/manager.py +++ b/plugin-repos/of-the-day/manager.py @@ -48,9 +48,9 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) # Configuration - self.update_interval = config.get('update_interval', 3600) - self.display_rotate_interval = config.get('display_rotate_interval', 20) - self.subtitle_rotate_interval = config.get('subtitle_rotate_interval', 10) + 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', {}) @@ -90,7 +90,20 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.logger.info(f"Of The Day plugin initialized with {len(self.current_items)} categories") - def _register_fonts(self): + 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 _register_fonts(self) -> None: """Register fonts with the font manager.""" try: if not hasattr(self.plugin_manager, 'font_manager'): @@ -561,12 +574,14 @@ def _display_content(self, category_config: Dict, item_data: Dict): # Load fonts - match old manager try: title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) - except: + except OSError 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() - + try: body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) - except: + except OSError as e: + self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else ImageFont.load_default() # Get font heights @@ -730,9 +745,9 @@ def on_config_change(self, config: Dict[str, Any]) -> None: # Update configuration self.config = config - self.update_interval = config.get('update_interval', 3600) - self.display_rotate_interval = config.get('display_rotate_interval', 20) - self.subtitle_rotate_interval = config.get('subtitle_rotate_interval', 10) + 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', []) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 2e621006a..53da99917 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1024,7 +1024,7 @@ def _mask_values(d): for k, v in d.items(): if isinstance(v, dict): masked[k] = _mask_values(v) - elif isinstance(v, str) and v and not v.startswith('YOUR_'): + elif v not in (None, '') and not (isinstance(v, str) and v.startswith('YOUR_')): masked[k] = '••••••••' else: masked[k] = v @@ -2604,7 +2604,8 @@ def get_plugin_config(): if not plugin_config: plugin_config = { 'enabled': True, - 'display_duration': 15 + 'display_duration': 15, + 'live_priority': False } # Mask fields marked x-secret:true so API keys are never sent to the browser. @@ -4764,7 +4765,7 @@ def _drop_empty_secrets(d): nested = _drop_empty_secrets(v) if nested: result[k] = nested - elif v != '': + elif not (isinstance(v, str) and v.strip() == ''): result[k] = v return result secrets_config = _drop_empty_secrets(secrets_config) @@ -4788,7 +4789,7 @@ def _filter_empty_secrets(d): nested = _filter_empty_secrets(v) if nested: filtered[k] = nested - elif v is not None and v != '': + 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) From e8e4071b94945e11c02b340b346efff3a33c04ce Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:25:10 -0500 Subject: [PATCH 12/14] fix(minor): address CodeRabbit minor/nitpick findings of-the-day/manager.py: - Add _get_fonts() method that loads title/body fonts once and caches them, replacing per-render ImageFont.truetype() calls to avoid disk I/O on every display() call on Pi hardware - Add clear() before PIL image creation in _display_no_data and _display_error to prevent stale buffer bleed-through - Fix bare except: in _display_no_data and _display_error font loading -> except OSError with warning log - Add Tuple to typing imports for _get_fonts() return annotation - Store _title_font/_body_font as None-initialized instance vars CLAUDE.md: - Fix weather secrets key: weather.api_key -> ledmatrix-weather.api_key - Add language identifiers to bare fenced blocks (MD040): directory tree and plugin lifecycle/file structure -> text - Add blank line before API endpoints table (MD058) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 9 ++-- plugin-repos/of-the-day/manager.py | 66 ++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c576a695f..59dd6e90f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ LEDMatrix is a Raspberry Pi-based LED matrix display controller with a plugin ar ## Directory Structure -``` +```text LEDMatrix/ ├── run.py # Main entry point (display controller) ├── display_controller.py # Legacy top-level shim (do not modify) @@ -187,7 +187,7 @@ Copy from `config/config.template.json`. Key sections: ### Secrets Config: `config/config_secrets.json` Copy from `config/config_secrets.template.json`. Contains API keys: -- `weather.api_key` — OpenWeatherMap +- `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` @@ -202,7 +202,7 @@ Config can be reloaded without restart. Set `LEDMATRIX_HOT_RELOAD=false` to disa ## Plugin System ### Plugin Lifecycle -``` +```text UNLOADED → LOADED → ENABLED → RUNNING → (back to ENABLED) ↓ ERROR @@ -243,7 +243,7 @@ class MyPlugin(BasePlugin): - `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) @@ -363,6 +363,7 @@ def get_vegas_segment_width(self): - Plugin operations are serialized via `PluginOperationQueue` to prevent conflicts ### Key API Endpoints + | Method | Path | Description | |--------|------|-------------| | GET | `/api/v3/config/main` | Read main config | diff --git a/plugin-repos/of-the-day/manager.py b/plugin-repos/of-the-day/manager.py index 95ec81c85..0ed614831 100644 --- a/plugin-repos/of-the-day/manager.py +++ b/plugin-repos/of-the-day/manager.py @@ -19,7 +19,7 @@ import logging import time from datetime import date -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont from pathlib import Path @@ -78,6 +78,10 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.subtitle_color = (200, 200, 200) self.content_color = (180, 180, 180) 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() @@ -103,6 +107,30 @@ def _get_positive_float(self, config: Dict[str, Any], key: str, default: float) 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: + try: + self._body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) + except OSError as e: + self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") + self._body_font = ( + self.display_manager.extra_small_font + if hasattr(self.display_manager, 'extra_small_font') + else ImageFont.load_default() + ) + return self._title_font, self._body_font + def _register_fonts(self) -> None: """Register fonts with the font manager.""" try: @@ -571,18 +599,8 @@ def _display_content(self, category_config: Dict, item_data: Dict): # Use display_manager's image and draw directly draw = self.display_manager.draw - # Load fonts - match old manager - try: - 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") - title_font = self.display_manager.small_font if hasattr(self.display_manager, 'small_font') else ImageFont.load_default() - - try: - body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) - except OSError as e: - self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") - body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else ImageFont.load_default() + # Load fonts (cached after first call to avoid per-frame disk I/O) + title_font, body_font = self._get_fonts() # Get font heights try: @@ -692,35 +710,39 @@ def _display_content(self, category_config: Dict, item_data: Dict): 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: + 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: + 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() From 13fc310c36340ad7f4a2efcbf269e9a6f10f05f3 Mon Sep 17 00:00:00 2001 From: 5ymb01 <5ymb01@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:14:04 -0500 Subject: [PATCH 13/14] fix(quality): code quality fixes and test infrastructure repairs display_manager.py: - Add 4x6 BDF font loading with set_pixel_sizes for compact body text - Initialize 5x7 BDF with set_pixel_sizes(0, 7) for proper metrics api_v3.py: - Fix on-demand display loading wrong plugin after service restart of-the-day manager.py: - Switch body text from TTF to BDF 4x6 font for crisp LED rendering - Replace logging.getLogger with get_logger per project standards web-ui-info manager.py: - Replace bare except with except Exception - Replace logging.getLogger with get_logger per project standards test infrastructure: - Add test/__init__.py to enable plugin test collection (6 tests unblocked) - Fix test_web_api mock: use get_raw_file_content (matches endpoint behavior) - Fix test_display_controller: route schedule config through config_service - Fix test_config_validation_edge_cases: use ConfigManager API correctly Co-Authored-By: Claude Opus 4.6 --- plugin-repos/of-the-day/manager.py | 95 ++++++++++--------- .../of-the-day/web_ui/file_manager.html | 43 ++++++++- plugin-repos/web-ui-info/manager.py | 6 +- src/display_manager.py | 16 ++++ test/__init__.py | 1 + test/test_config_validation_edge_cases.py | 29 +++--- test/test_display_controller.py | 21 ++-- test/test_web_api.py | 8 +- web_interface/blueprints/api_v3.py | 18 +++- 9 files changed, 161 insertions(+), 76 deletions(-) create mode 100644 test/__init__.py diff --git a/plugin-repos/of-the-day/manager.py b/plugin-repos/of-the-day/manager.py index 0ed614831..bd1b68559 100644 --- a/plugin-repos/of-the-day/manager.py +++ b/plugin-repos/of-the-day/manager.py @@ -16,7 +16,6 @@ import os import json -import logging import time from datetime import date from typing import Dict, Any, List, Optional, Tuple @@ -24,8 +23,9 @@ from pathlib import Path from src.plugin_system.base_plugin import BasePlugin +from src.logging_config import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class OfTheDayPlugin(BasePlugin): @@ -75,8 +75,8 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # Colors self.title_color = (255, 255, 255) - self.subtitle_color = (200, 200, 200) - self.content_color = (180, 180, 180) + 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) @@ -120,15 +120,15 @@ def _get_fonts(self) -> Tuple[ImageFont.ImageFont, ImageFont.ImageFont]: else ImageFont.load_default() ) if self._body_font is None: - try: - self._body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) - except OSError as e: - self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") - self._body_font = ( - self.display_manager.extra_small_font - if hasattr(self.display_manager, 'extra_small_font') - else ImageFont.load_default() - ) + # 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: @@ -485,11 +485,12 @@ def _display_title(self, category_config: Dict, item_data: Dict): 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() - try: - body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) - except Exception as e: - self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") - body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_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: @@ -503,11 +504,15 @@ def _display_title(self, category_config: Dict, item_data: Dict): self.logger.warning(f"Error getting body font height: {e}, using default 8") body_height = 8 - # Layout matching old manager: margin_top = 8 - margin_top = 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')) @@ -536,36 +541,36 @@ def _display_title(self, category_config: Dict, item_data: Dict): title, x=title_x, y=title_y, - color=self.title_color, + 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=self.title_color, width=1) - + 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 @@ -578,13 +583,13 @@ def _display_title(self, category_config: Dict, item_data: Dict): 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=self.subtitle_color, + color=subtitle_color, font=body_font ) current_y += body_height + 1 @@ -612,11 +617,15 @@ def _display_content(self, category_config: Dict, item_data: Dict): except Exception: body_height = 8 - # Layout matching old manager: margin_top = 8 - margin_top = 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}") @@ -643,16 +652,16 @@ def _display_content(self, category_config: Dict, item_data: Dict): title, x=title_x, y=title_y, - color=self.title_color, + 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=self.title_color, width=1) + 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 @@ -669,15 +678,15 @@ def _display_content(self, category_config: Dict, item_data: Dict): 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(2, int(extra_space * 0.3)) + 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 - space_after_underline = 4 + # 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 to match old manager's shift + 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): @@ -698,7 +707,7 @@ def _display_content(self, category_config: Dict, item_data: Dict): line, x=line_x, y=current_y, - color=self.subtitle_color, + color=content_color, font=body_font ) diff --git a/plugin-repos/of-the-day/web_ui/file_manager.html b/plugin-repos/of-the-day/web_ui/file_manager.html index 51a7d7363..05adfa553 100644 --- a/plugin-repos/of-the-day/web_ui/file_manager.html +++ b/plugin-repos/of-the-day/web_ui/file_manager.html @@ -361,7 +361,7 @@

File Explorer

-