Jelajahi Sumber

Optimize performance. fix previews loading when searching

tuanchris 2 bulan lalu
induk
melakukan
e8071fe375

+ 13 - 13
modules/core/pattern_manager.py

@@ -688,7 +688,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
         start_time = time.time()
         start_time = time.time()
         if state.led_controller:
         if state.led_controller:
             logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
             logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
-            state.led_controller.effect_playing(state.dw_led_playing_effect)
+            await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
             # Cancel idle timeout when playing starts
             # Cancel idle timeout when playing starts
             idle_timeout_manager.cancel_timeout()
             idle_timeout_manager.cancel_timeout()
 
 
@@ -705,7 +705,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                 if state.stop_requested:
                 if state.stop_requested:
                     logger.info("Execution stopped by user")
                     logger.info("Execution stopped by user")
                     if state.led_controller:
                     if state.led_controller:
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                         start_idle_led_timeout()
                     break
                     break
 
 
@@ -713,7 +713,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                     logger.info("Skipping pattern...")
                     logger.info("Skipping pattern...")
                     await connection_manager.check_idle_async()
                     await connection_manager.check_idle_async()
                     if state.led_controller:
                     if state.led_controller:
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                         start_idle_led_timeout()
                     break
                     break
 
 
@@ -732,12 +732,12 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         # Turn off LED controller if scheduled pause and control_wled is enabled
                         # Turn off LED controller if scheduled pause and control_wled is enabled
                         if state.scheduled_pause_control_wled and state.led_controller:
                         if state.scheduled_pause_control_wled and state.led_controller:
                             logger.info("Turning off LED lights during Still Sands period")
                             logger.info("Turning off LED lights during Still Sands period")
-                            state.led_controller.set_power(0)
+                            await state.led_controller.set_power_async(0)
 
 
                     # Only show idle effect if NOT in scheduled pause with LED control
                     # Only show idle effect if NOT in scheduled pause with LED control
                     # (manual pause always shows idle effect)
                     # (manual pause always shows idle effect)
                     if state.led_controller and not (scheduled_pause and state.scheduled_pause_control_wled):
                     if state.led_controller and not (scheduled_pause and state.scheduled_pause_control_wled):
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                         start_idle_led_timeout()
 
 
                     # Remember if we turned off LED controller for scheduled pause
                     # Remember if we turned off LED controller for scheduled pause
@@ -755,11 +755,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         # Turn LED controller back on if it was turned off for scheduled pause
                         # Turn LED controller back on if it was turned off for scheduled pause
                         if wled_was_off_for_scheduled:
                         if wled_was_off_for_scheduled:
                             logger.info("Turning LED lights back on as Still Sands period ended")
                             logger.info("Turning LED lights back on as Still Sands period ended")
-                            state.led_controller.set_power(1)
+                            await state.led_controller.set_power_async(1)
                             # CRITICAL: Give LED controller time to fully power on before sending more commands
                             # CRITICAL: Give LED controller time to fully power on before sending more commands
                             # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
                             # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
                             await asyncio.sleep(0.5)
                             await asyncio.sleep(0.5)
-                        state.led_controller.effect_playing(state.dw_led_playing_effect)
+                        await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                         # Cancel idle timeout when resuming from pause
                         # Cancel idle timeout when resuming from pause
                         idle_timeout_manager.cancel_timeout()
                         idle_timeout_manager.cancel_timeout()
 
 
@@ -796,7 +796,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
         # Set LED back to idle when pattern completes normally (not stopped early)
         # Set LED back to idle when pattern completes normally (not stopped early)
         if state.led_controller and not state.stop_requested:
         if state.led_controller and not state.stop_requested:
             logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
             logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
-            state.led_controller.effect_idle(state.dw_led_idle_effect)
+            await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
             start_idle_led_timeout()
             start_idle_led_timeout()
             logger.debug("LED effect set to idle after pattern completion")
             logger.debug("LED effect set to idle after pattern completion")
 
 
@@ -921,10 +921,10 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     wled_was_off_for_scheduled = False
                     wled_was_off_for_scheduled = False
                     if state.scheduled_pause_control_wled and state.led_controller:
                     if state.scheduled_pause_control_wled and state.led_controller:
                         logger.info("Turning off LED lights during Still Sands period")
                         logger.info("Turning off LED lights during Still Sands period")
-                        state.led_controller.set_power(0)
+                        await state.led_controller.set_power_async(0)
                         wled_was_off_for_scheduled = True
                         wled_was_off_for_scheduled = True
                     elif state.led_controller:
                     elif state.led_controller:
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                         start_idle_led_timeout()
 
 
                     # Wait until we're outside the scheduled pause period
                     # Wait until we're outside the scheduled pause period
@@ -936,9 +936,9 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                         if state.led_controller:
                         if state.led_controller:
                             if wled_was_off_for_scheduled:
                             if wled_was_off_for_scheduled:
                                 logger.info("Turning LED lights back on as Still Sands period ended")
                                 logger.info("Turning LED lights back on as Still Sands period ended")
-                                state.led_controller.set_power(1)
+                                await state.led_controller.set_power_async(1)
                                 await asyncio.sleep(0.5)  # Critical delay for LED controller
                                 await asyncio.sleep(0.5)  # Critical delay for LED controller
-                            state.led_controller.effect_playing(state.dw_led_playing_effect)
+                            await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                             idle_timeout_manager.cancel_timeout()
                             idle_timeout_manager.cancel_timeout()
 
 
                 # Handle pause between patterns
                 # Handle pause between patterns
@@ -995,7 +995,7 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
         state.playlist_mode = None
         state.playlist_mode = None
 
 
         if state.led_controller:
         if state.led_controller:
-            state.led_controller.effect_idle(state.dw_led_idle_effect)
+            await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
             start_idle_led_timeout()
             start_idle_led_timeout()
 
 
         logger.info("All requested patterns completed (or stopped) and state cleared")
         logger.info("All requested patterns completed (or stopped) and state cleared")

+ 30 - 0
modules/core/state.py

@@ -6,6 +6,10 @@ import logging
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Debounce timer for state saves (reduces SD card wear on Pi)
+_save_timer = None
+_save_lock = threading.Lock()
+
 class AppState:
 class AppState:
     def __init__(self):
     def __init__(self):
         # Private variables for properties
         # Private variables for properties
@@ -368,6 +372,32 @@ class AppState:
         except Exception as e:
         except Exception as e:
             print(f"Error saving state to {self.STATE_FILE}: {e}")
             print(f"Error saving state to {self.STATE_FILE}: {e}")
 
 
+    def save_debounced(self, delay: float = 2.0):
+        """
+        Schedule a state save after a delay, coalescing multiple rapid saves.
+        This reduces SD card writes on Raspberry Pi.
+
+        Args:
+            delay: Seconds to wait before saving (default 2.0)
+        """
+        global _save_timer
+        with _save_lock:
+            # Cancel any pending save
+            if _save_timer is not None:
+                _save_timer.cancel()
+            # Schedule new save
+            _save_timer = threading.Timer(delay, self._do_debounced_save)
+            _save_timer.daemon = True  # Don't block shutdown
+            _save_timer.start()
+
+    def _do_debounced_save(self):
+        """Internal method called by debounce timer."""
+        global _save_timer
+        with _save_lock:
+            _save_timer = None
+        self.save()
+        logger.debug("Debounced state save completed")
+
     def load(self):
     def load(self):
         """Load state from a JSON file. If the file doesn't exist, create it with default values."""
         """Load state from a JSON file. If the file doesn't exist, create it with default values."""
         if not os.path.exists(self.STATE_FILE):
         if not os.path.exists(self.STATE_FILE):

+ 28 - 0
modules/led/led_interface.py

@@ -2,6 +2,7 @@
 Unified LED interface for different LED control systems
 Unified LED interface for different LED control systems
 Provides a common abstraction layer for pattern manager integration.
 Provides a common abstraction layer for pattern manager integration.
 """
 """
+import asyncio
 from typing import Optional, Literal
 from typing import Optional, Literal
 from modules.led.led_controller import LEDController, effect_loading as wled_loading, effect_idle as wled_idle, effect_connected as wled_connected, effect_playing as wled_playing
 from modules.led.led_controller import LEDController, effect_loading as wled_loading, effect_idle as wled_idle, effect_connected as wled_connected, effect_playing as wled_playing
 
 
@@ -147,3 +148,30 @@ class LEDInterface:
     def get_controller(self):
     def get_controller(self):
         """Get the underlying controller instance (for advanced usage)"""
         """Get the underlying controller instance (for advanced usage)"""
         return self._controller
         return self._controller
+
+    # Async versions of methods for non-blocking calls from async context
+    # These use asyncio.to_thread() to avoid blocking the event loop
+
+    async def effect_loading_async(self) -> bool:
+        """Show loading effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_loading)
+
+    async def effect_idle_async(self, effect_name: Optional[str] = None) -> bool:
+        """Show idle effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_idle, effect_name)
+
+    async def effect_connected_async(self) -> bool:
+        """Show connected effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_connected)
+
+    async def effect_playing_async(self, effect_name: Optional[str] = None) -> bool:
+        """Show playing effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_playing, effect_name)
+
+    async def set_power_async(self, state: int) -> dict:
+        """Set power state (non-blocking)"""
+        return await asyncio.to_thread(self.set_power, state)
+
+    async def check_status_async(self) -> dict:
+        """Check controller status (non-blocking)"""
+        return await asyncio.to_thread(self.check_status)

+ 17 - 3
static/js/base.js

@@ -119,6 +119,10 @@ let reconnectAttempts = 0;
 const maxReconnectAttempts = 5;
 const maxReconnectAttempts = 5;
 const reconnectDelay = 3000; // 3 seconds
 const reconnectDelay = 3000; // 3 seconds
 let isEditingSpeed = false; // Track if user is editing speed
 let isEditingSpeed = false; // Track if user is editing speed
+
+// WebSocket UI update throttling for Pi performance
+let lastUIUpdate = 0;
+const UI_UPDATE_INTERVAL = 100; // Minimum ms between UI updates (10 updates/sec max)
 let playerPreviewData = null; // Store the current pattern's preview data for modal
 let playerPreviewData = null; // Store the current pattern's preview data for modal
 let playerPreviewCtx = null; // Store the canvas context for modal preview
 let playerPreviewCtx = null; // Store the canvas context for modal preview
 let playerAnimationId = null; // Store animation frame ID for modal
 let playerAnimationId = null; // Store animation frame ID for modal
@@ -179,6 +183,13 @@ function connectWebSocket() {
         try {
         try {
             const data = JSON.parse(event.data);
             const data = JSON.parse(event.data);
             if (data.type === 'status_update') {
             if (data.type === 'status_update') {
+                // Throttle UI updates for better Pi performance
+                const now = Date.now();
+                if (now - lastUIUpdate < UI_UPDATE_INTERVAL) {
+                    return; // Skip this update, too soon
+                }
+                lastUIUpdate = now;
+
                 // Update modal status with the full data
                 // Update modal status with the full data
                 syncModalControls(data.data);
                 syncModalControls(data.data);
                 
                 
@@ -367,7 +378,8 @@ function setupPlayerPreviewCanvas(ctx) {
     container.style.minHeight = `${finalSize}px`;
     container.style.minHeight = `${finalSize}px`;
     
     
     // Set the internal canvas size for high-DPI rendering
     // Set the internal canvas size for high-DPI rendering
-    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    // Cap at 1.5x for better Pi performance (was 2x forced)
+    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
     canvas.width = finalSize * pixelRatio;
     canvas.width = finalSize * pixelRatio;
     canvas.height = finalSize * pixelRatio;
     canvas.height = finalSize * pixelRatio;
     
     
@@ -421,7 +433,8 @@ function drawLoadingState(ctx) {
     if (!ctx) return;
     if (!ctx) return;
 
 
     const canvas = ctx.canvas;
     const canvas = ctx.canvas;
-    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    // Must match the pixelRatio used when setting canvas size
+    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
     const containerSize = canvas.width / pixelRatio;
     const containerSize = canvas.width / pixelRatio;
     const center = containerSize / 2;
     const center = containerSize / 2;
 
 
@@ -453,7 +466,8 @@ function drawPlayerPreview(ctx, progress) {
     if (!ctx || !playerPreviewData || playerPreviewData.length === 0) return;
     if (!ctx || !playerPreviewData || playerPreviewData.length === 0) return;
     
     
     const canvas = ctx.canvas;
     const canvas = ctx.canvas;
-    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    // Must match the pixelRatio used when setting canvas size
+    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
     const containerSize = canvas.width / pixelRatio;
     const containerSize = canvas.width / pixelRatio;
     const center = containerSize / 2;
     const center = containerSize / 2;
     const scale = (containerSize / 2) - 30;
     const scale = (containerSize / 2) - 30;

+ 15 - 8
static/js/index.js

@@ -502,22 +502,29 @@ async function processPendingBatch() {
 }
 }
 
 
 // Trigger preview loading for currently visible patterns
 // Trigger preview loading for currently visible patterns
-function triggerPreviewLoadingForVisible() {
+async function triggerPreviewLoadingForVisible() {
     // Get all pattern cards currently in the DOM
     // Get all pattern cards currently in the DOM
     const patternCards = document.querySelectorAll('.pattern-card');
     const patternCards = document.querySelectorAll('.pattern-card');
-    
+
+    // Collect all patterns that need checking
+    const patternsToCheck = [];
     patternCards.forEach(card => {
     patternCards.forEach(card => {
         const pattern = card.dataset.pattern;
         const pattern = card.dataset.pattern;
         const previewContainer = card.querySelector('.pattern-preview');
         const previewContainer = card.querySelector('.pattern-preview');
-        
-        // Check if this pattern needs preview loading
+
+        // Check if this pattern needs preview loading (only check in-memory cache here)
         if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
         if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
-            // Add to batch for immediate loading
-            addPatternToBatch(pattern, previewContainer);
+            patternsToCheck.push({ pattern, previewContainer });
         }
         }
     });
     });
-    
-    // Process any pending previews immediately
+
+    // Wait for all IndexedDB cache checks to complete before processing batch
+    // This prevents unnecessary API calls for patterns that are already cached in IndexedDB
+    await Promise.all(patternsToCheck.map(({ pattern, previewContainer }) =>
+        addPatternToBatch(pattern, previewContainer)
+    ));
+
+    // Process any pending previews that weren't found in cache
     if (pendingPatterns.size > 0) {
     if (pendingPatterns.size > 0) {
         processPendingBatch();
         processPendingBatch();
     }
     }

+ 1 - 1
templates/settings.html

@@ -830,7 +830,7 @@ input:checked + .slider:before {
     <h2
     <h2
       class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"
       class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"
     >
     >
-      MQTT / Home Assistant Integration
+      Home Assistant Integration
     </h2>
     </h2>
     <div class="px-6 py-5 space-y-6">
     <div class="px-6 py-5 space-y-6">
       <!-- MQTT Enable Toggle -->
       <!-- MQTT Enable Toggle -->