diff --git a/docs/ON_DEMAND_CACHE_MANAGEMENT.md b/docs/ON_DEMAND_CACHE_MANAGEMENT.md new file mode 100644 index 00000000..5b474482 --- /dev/null +++ b/docs/ON_DEMAND_CACHE_MANAGEMENT.md @@ -0,0 +1,203 @@ +# On-Demand Cache Management + +## Overview + +The on-demand feature uses several cache keys to manage state. Understanding these keys helps with troubleshooting and manual recovery. + +## Cache Keys Used + +### 1. `display_on_demand_request` +**Purpose**: Stores pending on-demand requests (start/stop actions) +**TTL**: 1 hour +**When Set**: When you click "Run On-Demand" or "Stop On-Demand" +**When Cleared**: Automatically after processing, or manually via cache management + +**Structure**: +```json +{ + "request_id": "uuid-string", + "action": "start" | "stop", + "plugin_id": "plugin-name", + "mode": "mode-name", + "duration": 30.0, + "pinned": true, + "timestamp": 1234567890.123 +} +``` + +### 2. `display_on_demand_config` +**Purpose**: Stores the active on-demand configuration (persists across restarts) +**TTL**: 1 hour +**When Set**: When on-demand mode is activated +**When Cleared**: When on-demand mode is stopped, or manually via cache management + +**Structure**: +```json +{ + "plugin_id": "plugin-name", + "mode": "mode-name", + "duration": 30.0, + "pinned": true, + "requested_at": 1234567890.123, + "expires_at": 1234567920.123 +} +``` + +### 3. `display_on_demand_state` +**Purpose**: Current on-demand state (read-only, published by display controller) +**TTL**: None (updated continuously) +**When Set**: Continuously updated by display controller +**When Cleared**: Automatically when on-demand ends, or manually via cache management + +**Structure**: +```json +{ + "active": true, + "mode": "mode-name", + "plugin_id": "plugin-name", + "requested_at": 1234567890.123, + "expires_at": 1234567920.123, + "duration": 30.0, + "pinned": true, + "status": "active" | "idle" | "restarting" | "error", + "error": null, + "last_event": "started", + "remaining": 25.5, + "last_updated": 1234567895.123 +} +``` + +### 4. `display_on_demand_processed_id` +**Purpose**: Tracks which request_id has been processed (prevents duplicate processing) +**TTL**: 1 hour +**When Set**: When a request is processed +**When Cleared**: Automatically expires, or manually via cache management + +**Structure**: Just a string (the request_id) + +## When Manual Clearing is Needed + +### Scenario 1: Stuck On-Demand State +**Symptoms**: +- Display stuck showing only one plugin +- "Stop On-Demand" button doesn't work +- Display controller shows on-demand as active but it shouldn't be + +**Solution**: Clear these keys: +- `display_on_demand_config` - Removes the active configuration +- `display_on_demand_state` - Resets the published state +- `display_on_demand_request` - Clears any pending requests + +**How to Clear**: Use the Cache Management tab in the web UI: +1. Go to Cache Management tab +2. Find the keys starting with `display_on_demand_` +3. Click "Delete" for each one +4. Restart the display service: `sudo systemctl restart ledmatrix` + +### Scenario 2: On-Demand Mode Switching Issues +**Symptoms**: +- On-demand mode not switching to requested plugin +- Logs show "Processing on-demand start request for plugin" but no "Activated on-demand for plugin" message +- Display stuck in previous mode instead of switching immediately + +**Solution**: Clear these keys: +- `display_on_demand_request` - Stops any pending request +- `display_on_demand_processed_id` - Allows new requests to be processed +- `display_on_demand_state` - Clears any stale state + +**How to Clear**: Same as Scenario 1, but focus on `display_on_demand_request` first. Note that on-demand now switches modes immediately without restarting the service. + +### Scenario 3: On-Demand Not Activating +**Symptoms**: +- Clicking "Run On-Demand" does nothing +- No errors in logs, but on-demand doesn't start + +**Solution**: Clear these keys: +- `display_on_demand_processed_id` - May be blocking new requests +- `display_on_demand_request` - Clear any stale requests + +**How to Clear**: Same as Scenario 1 + +### Scenario 4: After Service Crash or Unexpected Shutdown +**Symptoms**: +- Service was stopped unexpectedly (power loss, crash, etc.) +- On-demand state may be inconsistent + +**Solution**: Clear all on-demand keys: +- `display_on_demand_config` +- `display_on_demand_state` +- `display_on_demand_request` +- `display_on_demand_processed_id` + +**How to Clear**: Same as Scenario 1, clear all four keys + +## Does Clearing from Cache Management Tab Reset It? + +**Yes, but with caveats:** + +1. **Clearing `display_on_demand_state`**: + - ✅ Removes the published state from cache + - ⚠️ **Does NOT** immediately clear the in-memory state in the running display controller + - The display controller will continue using its internal state until it polls for updates or restarts + +2. **Clearing `display_on_demand_config`**: + - ✅ Removes the configuration from cache + - ⚠️ **Does NOT** immediately affect a running display controller + - The display controller only reads this on startup/restart + +3. **Clearing `display_on_demand_request`**: + - ✅ Prevents new requests from being processed + - ✅ Stops restart loops if that's the issue + - ⚠️ **Does NOT** stop an already-active on-demand session + +4. **Clearing `display_on_demand_processed_id`**: + - ✅ Allows previously-processed requests to be processed again + - Useful if a request got stuck + +## Best Practice for Manual Clearing + +**To fully reset on-demand state:** + +1. **Stop the display service** (if possible): + ```bash + sudo systemctl stop ledmatrix + ``` + +2. **Clear all on-demand cache keys** via Cache Management tab: + - `display_on_demand_config` + - `display_on_demand_state` + - `display_on_demand_request` + - `display_on_demand_processed_id` + +3. **Clear systemd environment variable** (if set): + ```bash + sudo systemctl unset-environment LEDMATRIX_ON_DEMAND_PLUGIN + ``` + +4. **Restart the display service**: + ```bash + sudo systemctl start ledmatrix + ``` + +## Automatic Cleanup + +The display controller automatically: +- Clears `display_on_demand_config` when on-demand mode is stopped +- Updates `display_on_demand_state` continuously +- Expires `display_on_demand_request` after processing +- Expires `display_on_demand_processed_id` after 1 hour + +## Troubleshooting + +If clearing cache keys doesn't resolve the issue: + +1. **Check logs**: `sudo journalctl -u ledmatrix -f` +2. **Check service status**: `sudo systemctl status ledmatrix` +3. **Check environment variables**: `sudo systemctl show ledmatrix | grep LEDMATRIX` +4. **Check cache files directly**: `ls -la /var/cache/ledmatrix/display_on_demand_*` + +## Related Files + +- `src/display_controller.py` - Main on-demand logic +- `web_interface/blueprints/api_v3.py` - API endpoints for on-demand +- `web_interface/templates/v3/partials/cache.html` - Cache management UI diff --git a/src/display_controller.py b/src/display_controller.py index 8450e788..8ecc7cee 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -89,6 +89,8 @@ class DisplayController: self.plugin_display_modes: Dict[str, List[str]] = {} self.on_demand_active = False self.on_demand_mode: Optional[str] = None + self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin + self.on_demand_mode_index: int = 0 # Current index in on-demand modes rotation self.on_demand_plugin_id: Optional[str] = None self.on_demand_duration: Optional[float] = None self.on_demand_requested_at: Optional[float] = None @@ -158,8 +160,43 @@ class DisplayController: discovered_plugins = self.plugin_manager.discover_plugins() logger.info("Discovered %d plugin(s)", len(discovered_plugins)) + # Check for on-demand plugin filter from cache + on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600) + on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None + + if on_demand_plugin_id: + logger.info("On-demand mode detected during initialization: filtering to plugin '%s' only", on_demand_plugin_id) + # Only load the on-demand plugin, but ensure it's enabled + if on_demand_plugin_id not in discovered_plugins: + error_msg = f"On-demand plugin '{on_demand_plugin_id}' not found in discovered plugins" + logger.error(error_msg) + logger.warning("Falling back to normal mode (all enabled plugins)") + on_demand_plugin_id = None + enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)] + else: + plugin_config = self.config.get(on_demand_plugin_id, {}) + was_disabled = not plugin_config.get('enabled', False) + if was_disabled: + logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id) + if on_demand_plugin_id not in self.config: + self.config[on_demand_plugin_id] = {} + self.config[on_demand_plugin_id]['enabled'] = True + enabled_plugins = [on_demand_plugin_id] + # Set on-demand state from cached config + self.on_demand_active = True + self.on_demand_plugin_id = on_demand_plugin_id + self.on_demand_mode = on_demand_config.get('mode') + self.on_demand_duration = on_demand_config.get('duration') + self.on_demand_pinned = on_demand_config.get('pinned', False) + self.on_demand_requested_at = on_demand_config.get('requested_at') + self.on_demand_expires_at = on_demand_config.get('expires_at') + self.on_demand_status = 'active' + self.on_demand_schedule_override = True + logger.info("On-demand mode: loading only plugin '%s'", on_demand_plugin_id) + else: + enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)] + # Count enabled plugins for progress tracking - enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)] enabled_count = len(enabled_plugins) logger.info("Loading %d enabled plugin(s) in parallel (max 4 concurrent)...", enabled_count) @@ -197,8 +234,7 @@ class DisplayController: # Submit all enabled plugins for loading future_to_plugin = { executor.submit(load_single_plugin, plugin_id): plugin_id - for plugin_id in discovered_plugins - if self.config.get(plugin_id, {}).get('enabled', False) + for plugin_id in enabled_plugins } # Process results as they complete @@ -258,6 +294,10 @@ class DisplayController: logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time) logger.info("Total available modes: %d", len(self.available_modes)) logger.info("Available modes: %s", self.available_modes) + + # If on-demand mode was restored from cache, populate on_demand_modes now that plugins are loaded + if self.on_demand_active and self.on_demand_plugin_id: + self._populate_on_demand_modes_from_plugin() except Exception: # pylint: disable=broad-except logger.exception("Plugin system initialization failed") @@ -606,6 +646,8 @@ class DisplayController: self.on_demand_last_event = None self.on_demand_active = False self.on_demand_mode = None + self.on_demand_modes = [] + self.on_demand_mode_index = 0 self.on_demand_plugin_id = None self.on_demand_duration = None self.on_demand_requested_at = None @@ -629,30 +671,146 @@ class DisplayController: return request_id = request.get('request_id') - if not request_id or request_id == self.on_demand_request_id: + if not request_id: return action = request.get('action') - logger.info("Received on-demand request %s: %s", request_id, action) + + # For stop requests, always process them (don't check processed_id) + # This allows stopping even if the same stop request was sent before + if action == 'stop': + logger.info("Received on-demand stop request %s", request_id) + # Always process stop requests, even if same request_id (user might click multiple times) + if self.on_demand_active: + self.on_demand_request_id = request_id + self._clear_on_demand(reason='requested-stop') + logger.info("On-demand mode cleared, resuming normal rotation") + else: + logger.debug("Stop request %s received but on-demand is not active", request_id) + # Still update request_id to acknowledge the request + self.on_demand_request_id = request_id + return + + # For start requests, check if already processed + if request_id == self.on_demand_request_id: + logger.debug("On-demand start request %s already processed (instance check)", request_id) + return + + # Also check persistent processed_id (for restart scenarios) + processed_request_id = self.cache_manager.get('display_on_demand_processed_id', max_age=3600) + if request_id == processed_request_id: + logger.debug("On-demand start request %s already processed (persisted check)", request_id) + return + + logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s)", + request_id, action, request.get('plugin_id'), request.get('mode')) + + # Mark as processed BEFORE processing (to prevent duplicate processing) + self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600) + self.on_demand_request_id = request_id + if action == 'start': + logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id')) self._activate_on_demand(request) - elif action == 'stop': - self._clear_on_demand(reason='requested-stop') else: logger.warning("Unknown on-demand action: %s", action) - self.on_demand_request_id = request_id def _resolve_mode_for_plugin(self, plugin_id: Optional[str], mode: Optional[str]) -> Optional[str]: """Resolve the display mode to use for on-demand activation.""" + # If mode is provided, check if it's actually a valid mode or just the plugin_id if mode: + # If mode matches plugin_id, it's likely the plugin_id was sent as mode + # Try to resolve it to an actual display mode + if plugin_id and mode == plugin_id: + # Mode is the plugin_id, resolve to first available display mode + if plugin_id in self.plugin_display_modes: + modes = self.plugin_display_modes.get(plugin_id, []) + if modes: + logger.debug("Resolving mode '%s' (plugin_id) to first display mode: %s", mode, modes[0]) + return modes[0] + # Check if mode is a valid display mode + elif mode in self.plugin_modes: + return mode + # Mode provided but not valid - might be plugin_id, try to resolve + elif plugin_id and plugin_id in self.plugin_display_modes: + modes = self.plugin_display_modes.get(plugin_id, []) + if modes and mode in modes: + return mode + elif modes: + logger.warning("Mode '%s' not found for plugin '%s', using first available: %s", + mode, plugin_id, modes[0]) + return modes[0] + # Mode doesn't match anything, return as-is (will fail validation later) return mode + # No mode provided, resolve from plugin_id if plugin_id and plugin_id in self.plugin_display_modes: modes = self.plugin_display_modes.get(plugin_id, []) if modes: return modes[0] return plugin_id + def _populate_on_demand_modes_from_plugin(self) -> None: + """ + Populate on_demand_modes from the on-demand plugin's display modes. + Called after plugin loading completes when on-demand state is restored from cache. + """ + if not self.on_demand_active or not self.on_demand_plugin_id: + return + + plugin_id = self.on_demand_plugin_id + + # Get all modes for this plugin + plugin_modes = self.plugin_display_modes.get(plugin_id, []) + if not plugin_modes: + # Fallback: find all modes that belong to this plugin + plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == plugin_id] + + # Filter to only include modes that exist in plugin_modes + available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes] + + if not available_plugin_modes: + logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id) + self.on_demand_modes = [] + return + + # Prioritize live modes if they exist and have content + live_modes = [m for m in available_plugin_modes if m.endswith('_live')] + other_modes = [m for m in available_plugin_modes if not m.endswith('_live')] + + # Check if live modes have content + live_with_content = [] + for live_mode in live_modes: + plugin_instance = self.plugin_modes.get(live_mode) + if plugin_instance and hasattr(plugin_instance, 'has_live_content'): + try: + if plugin_instance.has_live_content(): + live_with_content.append(live_mode) + except Exception: + pass + + # Build mode list: live modes with content first, then other modes, then live modes without content + if live_with_content: + ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content] + else: + # No live content, skip live modes + ordered_modes = other_modes + + if not ordered_modes: + # Only live modes available but no content - use them anyway + ordered_modes = live_modes + + self.on_demand_modes = ordered_modes + # Set index to match the restored mode if available, otherwise start at 0 + if self.on_demand_mode and self.on_demand_mode in ordered_modes: + self.on_demand_mode_index = ordered_modes.index(self.on_demand_mode) + else: + self.on_demand_mode_index = 0 + + logger.info("Populated on-demand modes for plugin '%s': %s (starting at index %d: %s)", + plugin_id, ordered_modes, self.on_demand_mode_index, + ordered_modes[self.on_demand_mode_index] if ordered_modes else 'N/A') + def _activate_on_demand(self, request: Dict[str, Any]) -> None: """Activate on-demand mode for a specific plugin display.""" plugin_id = request.get('plugin_id') @@ -696,8 +854,50 @@ class DisplayController: if resolved_mode in self.available_modes: self.current_mode_index = self.available_modes.index(resolved_mode) + # Get all modes for this plugin + plugin_modes = self.plugin_display_modes.get(resolved_plugin_id, []) + if not plugin_modes: + # Fallback: find all modes that belong to this plugin + plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == resolved_plugin_id] + + # Filter to only include modes that exist in plugin_modes + available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes] + + if not available_plugin_modes: + logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id) + self._set_on_demand_error("no-modes") + return + + # Prioritize live modes if they exist and have content + live_modes = [m for m in available_plugin_modes if m.endswith('_live')] + other_modes = [m for m in available_plugin_modes if not m.endswith('_live')] + + # Check if live modes have content + live_with_content = [] + for live_mode in live_modes: + plugin_instance = self.plugin_modes.get(live_mode) + if plugin_instance and hasattr(plugin_instance, 'has_live_content'): + try: + if plugin_instance.has_live_content(): + live_with_content.append(live_mode) + except Exception: + pass + + # Build mode list: live modes with content first, then other modes, then live modes without content + if live_with_content: + ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content] + else: + # No live content, skip live modes + ordered_modes = other_modes + + if not ordered_modes: + # Only live modes available but no content - use them anyway + ordered_modes = live_modes + self.on_demand_active = True - self.on_demand_mode = resolved_mode + self.on_demand_mode = resolved_mode # Keep for backward compatibility + self.on_demand_modes = ordered_modes + self.on_demand_mode_index = 0 self.on_demand_plugin_id = resolved_plugin_id self.on_demand_duration = duration self.on_demand_requested_at = now @@ -708,9 +908,36 @@ class DisplayController: self.on_demand_last_event = 'started' self.on_demand_schedule_override = True self.force_change = True - self.current_display_mode = resolved_mode - logger.info("Activated on-demand mode '%s' for plugin '%s'", resolved_mode, resolved_plugin_id) + + # Clear display before switching to on-demand mode + try: + self.display_manager.clear() + self.display_manager.update_display() + except Exception as e: + logger.warning("Failed to clear display during on-demand activation: %s", e) + + # Start with first mode (or resolved_mode if it's in the list) + if resolved_mode in ordered_modes: + self.on_demand_mode_index = ordered_modes.index(resolved_mode) + self.current_display_mode = ordered_modes[self.on_demand_mode_index] + logger.info("Activated on-demand for plugin '%s' with %d modes: %s (starting at index %d: %s)", + resolved_plugin_id, len(ordered_modes), ordered_modes, + self.on_demand_mode_index, self.current_display_mode) self._publish_on_demand_state() + + # Store config for initialization filtering (allows plugin filtering on restart) + config_data = { + 'plugin_id': resolved_plugin_id, + 'mode': resolved_mode, + 'duration': duration, + 'pinned': pinned, + 'requested_at': now, + 'expires_at': self.on_demand_expires_at + } + # Use expiration time as TTL, but cap at 1 hour + ttl = min(3600, int(duration)) if duration else 3600 + self.cache_manager.set('display_on_demand_config', config_data, ttl=ttl) + logger.debug("Stored on-demand config for plugin filtering: %s", resolved_plugin_id) def _clear_on_demand(self, reason: Optional[str] = None) -> None: """Clear on-demand mode and resume normal rotation.""" @@ -722,6 +949,8 @@ class DisplayController: self.on_demand_active = False self.on_demand_mode = None + self.on_demand_modes = [] + self.on_demand_mode_index = 0 self.on_demand_plugin_id = None self.on_demand_duration = None self.on_demand_requested_at = None @@ -731,27 +960,41 @@ class DisplayController: self.on_demand_last_error = None self.on_demand_last_event = reason or 'cleared' self.on_demand_schedule_override = False + + # Clear on-demand configuration from cache + self.cache_manager.clear_cache('display_on_demand_config') if self.rotation_resume_index is not None and self.available_modes: self.current_mode_index = self.rotation_resume_index % len(self.available_modes) self.current_display_mode = self.available_modes[self.current_mode_index] + logger.info("Resuming rotation from saved index %d: mode '%s'", + self.rotation_resume_index, self.current_display_mode) elif self.available_modes: - # Default to first mode + # Default to first mode if no resume index self.current_mode_index = self.current_mode_index % len(self.available_modes) self.current_display_mode = self.available_modes[self.current_mode_index] + logger.info("Resuming rotation to mode '%s' (index %d)", + self.current_display_mode, self.current_mode_index) + else: + logger.warning("No available modes to resume rotation to") self.rotation_resume_index = None self.force_change = True - logger.info("Cleared on-demand mode (reason=%s), resuming rotation", reason) + logger.info("✓ ON-DEMAND MODE CLEARED (reason=%s), resuming normal rotation to mode: %s", + reason, self.current_display_mode) self._publish_on_demand_state() def _check_on_demand_expiration(self) -> None: """Expire on-demand mode if duration has elapsed.""" - if not self.on_demand_active or self.on_demand_expires_at is None: + if not self.on_demand_active: + return + + if self.on_demand_expires_at is None: return if time.time() >= self.on_demand_expires_at: - logger.info("On-demand mode '%s' expired", self.on_demand_mode) + logger.info("On-demand mode '%s' expired (duration: %s seconds)", + self.on_demand_mode, self.on_demand_duration) self._clear_on_demand(reason='expired') def _log_memory_stats_if_due(self) -> None: @@ -900,11 +1143,26 @@ class DisplayController: except ValueError: pass - if self.on_demand_active and self.on_demand_mode: - active_mode = self.on_demand_mode - if self.current_display_mode != active_mode: - self.current_display_mode = active_mode - self.force_change = True + if self.on_demand_active: + # Guard against empty on_demand_modes + if not self.on_demand_modes: + logger.warning("On-demand active but no modes available, clearing on-demand mode") + self._clear_on_demand(reason='no-modes-available') + active_mode = self.current_display_mode + else: + # Rotate through on-demand plugin modes + if self.on_demand_mode_index < len(self.on_demand_modes): + active_mode = self.on_demand_modes[self.on_demand_mode_index] + if self.current_display_mode != active_mode: + self.current_display_mode = active_mode + self.force_change = True + else: + # Reset to first mode if index is out of bounds + self.on_demand_mode_index = 0 + active_mode = self.on_demand_modes[0] + if self.current_display_mode != active_mode: + self.current_display_mode = active_mode + self.force_change = True else: active_mode = self.current_display_mode @@ -994,14 +1252,34 @@ class DisplayController: display_result = False display_failed_due_to_exception = True # Mark that this was an exception, not just no content - # If display() returned False, skip to next mode immediately (unless on-demand) + # If display() returned False, skip to next mode immediately if not display_result: if self.on_demand_active: - # Stay on on-demand mode even if no content - show "waiting" message - logger.info("No content for on-demand mode %s, staying on mode", active_mode) - self._sleep_with_plugin_updates(5) - self._publish_on_demand_state() - continue + # Skip to next on-demand mode if no content + logger.info("No content for on-demand mode %s, skipping to next mode", active_mode) + + # Guard against empty on_demand_modes to prevent ZeroDivisionError + if not self.on_demand_modes or len(self.on_demand_modes) == 0: + logger.warning("On-demand active but no modes configured, skipping rotation") + logger.debug("on_demand_modes is empty, cannot rotate to next mode") + # Skip rotation and continue to next iteration + continue + + # Move to next mode in rotation (only if on_demand_modes is non-empty) + self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) + next_mode = self.on_demand_modes[self.on_demand_mode_index] + + # Only log when next_mode is valid + if next_mode: + logger.info("Rotating to next on-demand mode: %s (index %d/%d)", + next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) + self.current_display_mode = next_mode + self.force_change = True + self._publish_on_demand_state() + continue + else: + logger.warning("Next on-demand mode is invalid, skipping rotation") + continue else: logger.info("No content to display for %s, skipping to next mode", active_mode) # Don't clear display when immediately moving to next mode - this causes black flashes @@ -1364,9 +1642,21 @@ class DisplayController: # Move to next mode if self.on_demand_active: - # Stay on the same mode while on-demand is active - self._publish_on_demand_state() - continue + # Guard against empty on_demand_modes to prevent ZeroDivisionError + if not self.on_demand_modes: + logger.warning("On-demand active but no modes available, clearing on-demand mode") + self._clear_on_demand(reason='no-modes-available') + # Fall through to normal rotation + else: + # Rotate to next on-demand mode + self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) + next_mode = self.on_demand_modes[self.on_demand_mode_index] + logger.info("Rotating to next on-demand mode: %s (index %d/%d)", + next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) + self.current_display_mode = next_mode + self.force_change = True + self._publish_on_demand_state() + continue # Check for live priority - don't rotate if current plugin has live content should_rotate = True diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index a291dd90..b6bfd4fb 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -125,12 +125,16 @@ class PluginStoreManager: # Rate limit or forbidden (but token might be valid) # Check if it's a rate limit issue if 'rate limit' in response.text.lower(): + # Rate limit: return error but don't cache (rate limits are temporary) error_msg = "Rate limit exceeded" + result = (False, error_msg) + return result else: + # Token lacks permissions: cache the result (permissions don't change) error_msg = "Token lacks required permissions" - result = (False, error_msg) - self._token_validation_cache[cache_key] = (False, time.time(), error_msg) - return result + result = (False, error_msg) + self._token_validation_cache[cache_key] = (False, time.time(), error_msg) + return result else: # Other error error_msg = f"GitHub API error: {response.status_code}" diff --git a/systemd/ledmatrix.service b/systemd/ledmatrix.service index bf161ca3..1fbce457 100644 --- a/systemd/ledmatrix.service +++ b/systemd/ledmatrix.service @@ -12,6 +12,11 @@ RestartSec=10 StandardOutput=journal StandardError=journal SyslogIdentifier=ledmatrix +# Support for on-demand plugin filtering via environment variable +# The environment variable LEDMATRIX_ON_DEMAND_PLUGIN can be set via: +# sudo systemctl set-environment LEDMATRIX_ON_DEMAND_PLUGIN= +# Or by using an EnvironmentFile (see below) +# EnvironmentFile=__PROJECT_ROOT_DIR__/config/on_demand_env.conf [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index dcc4653b..281bd4ff 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -6,9 +6,12 @@ import subprocess import time import hashlib import uuid +import logging from datetime import datetime from pathlib import Path +logger = logging.getLogger(__name__) + # Import new infrastructure from src.web_interface.api_helpers import success_response, error_response, validate_request_json from src.web_interface.errors import ErrorCode @@ -634,7 +637,7 @@ def save_main_config(): logging.error(error_msg) return error_response( ErrorCode.CONFIG_SAVE_FAILED, - f"Error saving configuration: {str(e)}", + f"Error saving configuration: {e}", details=traceback.format_exc(), status_code=500 ) @@ -1228,24 +1231,20 @@ def start_on_demand_display(): if not resolved_plugin: return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 + # Note: On-demand can work with disabled plugins - the display controller + # will temporarily enable them during initialization if needed + # We don't block the request here, but log it for debugging if api_v3.config_manager and resolved_plugin: config = api_v3.config_manager.load_config() plugin_config = config.get(resolved_plugin, {}) if 'enabled' in plugin_config and not plugin_config.get('enabled', False): - return jsonify({ - 'status': 'error', - 'message': f'Plugin {resolved_plugin} is disabled in configuration' - }), 400 - - # Check if display service is running (or will be started) - service_status = _get_display_service_status() - if not service_status.get('active') and not start_service: - return jsonify({ - 'status': 'error', - 'message': 'Display service is not running. Please start the display service or enable "Start Service" option.', - 'service_status': service_status - }), 400 + logger.info( + "On-demand request for disabled plugin '%s' - will be temporarily enabled", + resolved_plugin, + ) + # Set the on-demand request in cache FIRST (before starting service) + # This ensures the request is available when the service starts/restarts cache = _ensure_cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) request_payload = { @@ -1259,6 +1258,26 @@ def start_on_demand_display(): } cache.set('display_on_demand_request', request_payload) + # Check if display service is running (or will be started) + service_status = _get_display_service_status() + service_was_running = service_status.get('active', False) + + # Stop the display service first to ensure clean state when we will restart it + if service_was_running and start_service: + import time as time_module + print("Stopping display service before starting on-demand mode...") + _stop_display_service() + # Wait a brief moment for the service to fully stop + time_module.sleep(1.5) + print("Display service stopped, now starting with on-demand request...") + + if not service_status.get('active') and not start_service: + return jsonify({ + 'status': 'error', + 'message': 'Display service is not running. Please start the display service or enable "Start Service" option.', + 'service_status': service_status + }), 400 + service_result = None if start_service: service_result = _ensure_display_service_running() @@ -1269,6 +1288,9 @@ def start_on_demand_display(): 'message': 'Failed to start display service. Please check service logs or start it manually.', 'service_result': service_result }), 500 + + # Service was restarted (or started fresh) with on-demand request in cache + # The display controller will read the request during initialization or when it polls response_data = { 'request_id': request_id, @@ -1293,6 +1315,8 @@ def stop_on_demand_display(): data = request.get_json(silent=True) or {} stop_service = data.get('stop_service', False) + # Set the stop request in cache FIRST + # The display controller will poll this and restart without the on-demand filter cache = _ensure_cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) request_payload = { @@ -1301,7 +1325,10 @@ def stop_on_demand_display(): 'timestamp': time.time() } cache.set('display_on_demand_request', request_payload) - + + # Note: The display controller's _clear_on_demand() will handle the restart + # to restore normal operation with all plugins + service_result = None if stop_service: service_result = _stop_display_service() @@ -2769,9 +2796,9 @@ def get_github_auth_status(): try: if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - + token = api_v3.plugin_store_manager.github_token - + # Check if GitHub token is configured if not token or len(token) == 0: return jsonify({ @@ -2784,10 +2811,10 @@ def get_github_auth_status(): 'error': None } }) - + # Validate the token is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token) - + if is_valid: return jsonify({ 'status': 'success', @@ -2946,17 +2973,17 @@ def _get_schema_property(schema, key_path): def _is_field_required(key_path, schema): """ Check if a field is required according to the schema. - + Args: key_path: Dot-separated path like "mqtt.username" schema: The JSON schema dict - + Returns: True if field is required, False otherwise """ if not schema or 'properties' not in schema: return False - + parts = key_path.split('.') if len(parts) == 1: # Top-level field @@ -2966,12 +2993,12 @@ def _is_field_required(key_path, schema): # Nested field - navigate to parent object parent_path = '.'.join(parts[:-1]) field_name = parts[-1] - + # Get parent property parent_prop = _get_schema_property(schema, parent_path) if not parent_prop or 'properties' not in parent_prop: return False - + # Check if field is required in parent required = parent_prop.get('required', []) return field_name in required @@ -3116,7 +3143,7 @@ def _set_nested_value(config, key_path, value): # Skip setting if value is the sentinel if value is _SKIP_FIELD: return - + parts = key_path.split('.') current = config @@ -3320,7 +3347,7 @@ def save_plugin_config(): # Only set if not skipped if parsed_value is not _SKIP_FIELD: _set_nested_value(plugin_config, base_path, parsed_value) - + # Process remaining (non-indexed) fields # Skip any base paths that were processed as indexed arrays for key, value in form_data.items(): @@ -3340,7 +3367,7 @@ def save_plugin_config(): # Use helper to set nested values correctly (skips if _SKIP_FIELD) if parsed_value is not _SKIP_FIELD: _set_nested_value(plugin_config, key, parsed_value) - + # Post-process: Fix array fields that might have been incorrectly structured # This handles cases where array fields are stored as dicts (e.g., from indexed form fields) def fix_array_structures(config_dict, schema_props, prefix=''): diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 4f918c92..3aae8300 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -587,9 +587,22 @@ window.checkGitHubAuthStatus = function checkGitHubAuthStatus() { if (tokenStatus === 'invalid' && authData.error) { const warningText = warning.querySelector('p.text-sm.text-yellow-700'); if (warningText) { - // Preserve the structure but update the message - const errorMsg = authData.message || authData.error; - warningText.innerHTML = `Token Invalid: ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`; + // Clear existing content + warningText.textContent = ''; + + // Create safe error message with fallback + const errorMsg = (authData.message || authData.error || 'Unknown error').toString(); + + // Create element for "Token Invalid:" label + const strong = document.createElement('strong'); + strong.textContent = 'Token Invalid:'; + + // Create text node for error message and suffix + const errorText = document.createTextNode(` ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`); + + // Append elements safely (no innerHTML) + warningText.appendChild(strong); + warningText.appendChild(errorText); } } // For 'none' status, use the default message from HTML template @@ -1512,24 +1525,68 @@ function runUpdateAllPlugins() { }); } +// Initialize on-demand modal setup (runs unconditionally since modal is in base.html) +function initializeOnDemandModal() { + const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal'); + const cancelOnDemandBtn = document.getElementById('cancel-on-demand'); + const onDemandForm = document.getElementById('on-demand-form'); + const onDemandModal = document.getElementById('on-demand-modal'); + + if (closeOnDemandModalBtn && !closeOnDemandModalBtn.dataset.initialized) { + closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true)); + const newBtn = document.getElementById('close-on-demand-modal'); + if (newBtn) { + newBtn.dataset.initialized = 'true'; + newBtn.addEventListener('click', closeOnDemandModal); + } + } + if (cancelOnDemandBtn && !cancelOnDemandBtn.dataset.initialized) { + cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true)); + const newBtn = document.getElementById('cancel-on-demand'); + if (newBtn) { + newBtn.dataset.initialized = 'true'; + newBtn.addEventListener('click', closeOnDemandModal); + } + } + if (onDemandForm && !onDemandForm.dataset.initialized) { + onDemandForm.replaceWith(onDemandForm.cloneNode(true)); + const newForm = document.getElementById('on-demand-form'); + if (newForm) { + newForm.dataset.initialized = 'true'; + newForm.addEventListener('submit', submitOnDemandRequest); + } + } + if (onDemandModal && !onDemandModal.dataset.initialized) { + onDemandModal.dataset.initialized = 'true'; + onDemandModal.onclick = closeOnDemandModalOnBackdrop; + } +} + // Store the real implementation and replace the stub window.__openOnDemandModalImpl = function(pluginId) { + console.log('[__openOnDemandModalImpl] Called with pluginId:', pluginId); const plugin = findInstalledPlugin(pluginId); + console.log('[__openOnDemandModalImpl] Found plugin:', plugin ? plugin.id : 'NOT FOUND'); if (!plugin) { + console.warn('[__openOnDemandModalImpl] Plugin not found, installedPlugins:', window.installedPlugins?.length || 0); if (typeof showNotification === 'function') { showNotification(`Plugin ${pluginId} not found`, 'error'); } return; } + // Note: On-demand can work with disabled plugins - the backend will temporarily enable them + // We still log it for debugging but don't block the modal if (!plugin.enabled) { - if (typeof showNotification === 'function') { - showNotification('Enable the plugin before running it on-demand.', 'error'); - } - return; + console.log('[__openOnDemandModalImpl] Plugin is disabled, but on-demand will temporarily enable it'); } currentOnDemandPluginId = pluginId; + console.log('[__openOnDemandModalImpl] Setting currentOnDemandPluginId to:', pluginId); + + // Ensure modal is initialized + console.log('[__openOnDemandModalImpl] Initializing modal...'); + initializeOnDemandModal(); const modal = document.getElementById('on-demand-modal'); const modeSelect = document.getElementById('on-demand-mode'); @@ -1539,10 +1596,30 @@ window.__openOnDemandModalImpl = function(pluginId) { const startServiceCheckbox = document.getElementById('on-demand-start-service'); const modalTitle = document.getElementById('on-demand-modal-title'); + console.log('[__openOnDemandModalImpl] Modal elements check:', { + modal: !!modal, + modeSelect: !!modeSelect, + modeHint: !!modeHint, + durationInput: !!durationInput, + pinnedCheckbox: !!pinnedCheckbox, + startServiceCheckbox: !!startServiceCheckbox, + modalTitle: !!modalTitle + }); + if (!modal || !modeSelect || !modeHint || !durationInput || !pinnedCheckbox || !startServiceCheckbox || !modalTitle) { - console.error('On-demand modal elements not found'); + console.error('On-demand modal elements not found', { + modal: !!modal, + modeSelect: !!modeSelect, + modeHint: !!modeHint, + durationInput: !!durationInput, + pinnedCheckbox: !!pinnedCheckbox, + startServiceCheckbox: !!startServiceCheckbox, + modalTitle: !!modalTitle + }); return; } + + console.log('[__openOnDemandModalImpl] All elements found, opening modal...'); modalTitle.textContent = `Run ${resolvePluginDisplayName(pluginId)} On-Demand`; modeSelect.innerHTML = ''; @@ -1589,7 +1666,43 @@ window.__openOnDemandModalImpl = function(pluginId) { console.error('Error checking service status:', error); }); - modal.style.display = 'flex'; + console.log('[__openOnDemandModalImpl] Setting modal display to flex'); + // Force modal to be visible and properly positioned + // Remove all inline styles that might interfere + modal.removeAttribute('style'); + // Set explicit positioning to ensure it's visible + modal.style.cssText = 'position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; display: flex !important; visibility: visible !important; opacity: 1 !important; z-index: 9999 !important; margin: 0 !important; padding: 0 !important;'; + + // Ensure modal content is centered + const modalContent = modal.querySelector('.modal-content'); + if (modalContent) { + modalContent.style.margin = 'auto'; + modalContent.style.maxHeight = '90vh'; + modalContent.style.overflowY = 'auto'; + } + + // Scroll to top of page to ensure modal is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); + + // Force a reflow to ensure styles are applied + modal.offsetHeight; + console.log('[__openOnDemandModalImpl] Modal display set, should be visible now. Modal element:', modal); + console.log('[__openOnDemandModalImpl] Modal computed styles:', { + display: window.getComputedStyle(modal).display, + visibility: window.getComputedStyle(modal).visibility, + opacity: window.getComputedStyle(modal).opacity, + zIndex: window.getComputedStyle(modal).zIndex, + position: window.getComputedStyle(modal).position + }); + // Also check if modal is actually in the viewport + const rect = modal.getBoundingClientRect(); + console.log('[__openOnDemandModalImpl] Modal bounding rect:', { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + visible: rect.width > 0 && rect.height > 0 + }); }; // Replace the stub with the real implementation @@ -1605,7 +1718,10 @@ function closeOnDemandModal() { function submitOnDemandRequest(event) { event.preventDefault(); + console.log('[submitOnDemandRequest] Form submitted, currentOnDemandPluginId:', currentOnDemandPluginId); + if (!currentOnDemandPluginId) { + console.error('[submitOnDemandRequest] No plugin ID set'); if (typeof showNotification === 'function') { showNotification('Select a plugin before starting on-demand mode.', 'error'); } @@ -1614,8 +1730,11 @@ function submitOnDemandRequest(event) { const form = document.getElementById('on-demand-form'); if (!form) { + console.error('[submitOnDemandRequest] Form not found'); return; } + + console.log('[submitOnDemandRequest] Form found, processing...'); const formData = new FormData(form); const mode = formData.get('mode'); @@ -1637,6 +1756,7 @@ function submitOnDemandRequest(event) { } } + console.log('[submitOnDemandRequest] Payload:', payload); markOnDemandLoading(); fetch('/api/v3/display/on-demand/start', { @@ -1646,8 +1766,12 @@ function submitOnDemandRequest(event) { }, body: JSON.stringify(payload) }) - .then(response => response.json()) + .then(response => { + console.log('[submitOnDemandRequest] Response status:', response.status); + return response.json(); + }) .then(result => { + console.log('[submitOnDemandRequest] Response data:', result); if (result.status === 'success') { if (typeof showNotification === 'function') { const pluginName = resolvePluginDisplayName(currentOnDemandPluginId); @@ -1656,13 +1780,14 @@ function submitOnDemandRequest(event) { closeOnDemandModal(); setTimeout(() => loadOnDemandStatus(true), 700); } else { + console.error('[submitOnDemandRequest] Request failed:', result); if (typeof showNotification === 'function') { showNotification(result.message || 'Failed to start on-demand mode', 'error'); } } }) .catch(error => { - console.error('Error starting on-demand mode:', error); + console.error('[submitOnDemandRequest] Error starting on-demand mode:', error); if (typeof showNotification === 'function') { showNotification('Error starting on-demand mode: ' + error.message, 'error'); } @@ -5975,6 +6100,18 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning window.checkGitHubAuthStatus(); } +// Initialize on-demand modal immediately since it's in base.html +if (typeof initializeOnDemandModal === 'function') { + // Run immediately and also after DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeOnDemandModal); + } else { + initializeOnDemandModal(); + } + // Also try after a short delay to ensure elements are available + setTimeout(initializeOnDemandModal, 100); +} + setTimeout(function() { const installedGrid = document.getElementById('installed-plugins-grid'); if (installedGrid) { diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index e765533f..d36beca9 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -937,7 +937,10 @@ const appElement = document.querySelector('[x-data="app()"]'); if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { appElement._x_dataStack[0].activeTab = plugin.id; - appElement._x_dataStack[0].updatePluginTabStates(); + // Only call updatePluginTabStates if it exists + if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') { + appElement._x_dataStack[0].updatePluginTabStates(); + } } } }; @@ -2024,10 +2027,7 @@ }); }, runOnDemand() { - if (!plugin.enabled) { - this.notify('Enable the plugin before running it on-demand.', 'warning'); - return; - } + // Note: On-demand can work with disabled plugins - the backend will temporarily enable them if (typeof window.openOnDemandModal === 'function') { window.openOnDemandModal(plugin.id); } else { @@ -2123,7 +2123,9 @@ // Ensure content loads for the active tab this.$watch('activeTab', (newTab, oldTab) => { // Update plugin tab states when activeTab changes - this.updatePluginTabStates(); + if (typeof this.updatePluginTabStates === 'function') { + this.updatePluginTabStates(); + } // Trigger content load when tab changes this.$nextTick(() => { this.loadTabContent(newTab); @@ -2345,7 +2347,9 @@ tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`; tabButton.onclick = () => { this.activeTab = plugin.id; - this.updatePluginTabStates(); + if (typeof this.updatePluginTabStates === 'function') { + this.updatePluginTabStates(); + } }; tabButton.innerHTML = ` ${this.escapeHtml(plugin.name || plugin.id)} @@ -4815,6 +4819,76 @@ + + + diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 7b8f7619..52fa4727 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -308,8 +308,7 @@
@@ -321,7 +320,7 @@
{% if not plugin.enabled %} -

Enable this plugin before launching on-demand.

+

Plugin is disabled, but on-demand will temporarily enable it.

{% endif %} diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index 3e100acd..e6ea736b 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -330,75 +330,7 @@ - - +