Jelajahi Sumber

add idle timeout

tuanchris 3 bulan lalu
induk
melakukan
d3f61e912e
6 mengubah file dengan 327 tambahan dan 26 penghapusan
  1. 108 2
      main.py
  2. 10 1
      modules/core/pattern_manager.py
  3. 12 0
      modules/core/state.py
  4. 123 0
      static/js/led-control.js
  5. 12 1
      static/js/settings.js
  6. 62 22
      templates/led.html

+ 108 - 2
main.py

@@ -165,6 +165,51 @@ async def lifespan(app: FastAPI):
     # Start cache check in background immediately
     asyncio.create_task(delayed_cache_check())
 
+    # Start idle timeout monitor
+    async def idle_timeout_monitor():
+        """Monitor LED idle timeout and turn off LEDs when timeout expires."""
+        import time
+        while True:
+            try:
+                await asyncio.sleep(30)  # Check every 30 seconds
+
+                if not state.dw_led_idle_timeout_enabled:
+                    continue
+
+                if not state.led_controller or not state.led_controller.is_configured:
+                    continue
+
+                # Check if we're currently playing a pattern
+                is_playing = bool(state.current_playing_file or state.current_playlist)
+                if is_playing:
+                    # Reset activity time when playing
+                    state.dw_led_last_activity_time = time.time()
+                    continue
+
+                # If no activity time set, initialize it
+                if state.dw_led_last_activity_time is None:
+                    state.dw_led_last_activity_time = time.time()
+                    continue
+
+                # Calculate idle duration
+                idle_seconds = time.time() - state.dw_led_last_activity_time
+                timeout_seconds = state.dw_led_idle_timeout_minutes * 60
+
+                # Turn off LEDs if timeout expired
+                if idle_seconds >= timeout_seconds:
+                    status = state.led_controller.check_status()
+                    if status.get("power", False):  # Only turn off if currently on
+                        logger.info(f"Idle timeout ({state.dw_led_idle_timeout_minutes} minutes) expired, turning off LEDs")
+                        state.led_controller.set_power(0)
+                        # Reset activity time to prevent repeated turn-off attempts
+                        state.dw_led_last_activity_time = time.time()
+
+            except Exception as e:
+                logger.error(f"Error in idle timeout monitor: {e}")
+                await asyncio.sleep(60)  # Wait longer on error
+
+    asyncio.create_task(idle_timeout_monitor())
+
     yield  # This separates startup from shutdown code
 
     # Shutdown
@@ -1199,9 +1244,25 @@ async def set_led_config(request: LEDConfigRequest):
         status = state.led_controller.check_status()
         if not status.get("connected", False) and status.get("error"):
             error_msg = status["error"]
+            logger.warning(f"DW LED initialization failed: {error_msg}, but configuration saved for testing")
             state.led_controller = None
-            state.led_provider = "none"
-            raise HTTPException(status_code=400, detail=error_msg)
+            # Keep the provider setting for testing purposes
+            # state.led_provider remains "dw_leds" so settings can be saved/tested
+
+            # Save state even with error
+            state.save()
+
+            # Return success with warning instead of error
+            return {
+                "success": True,
+                "warning": error_msg,
+                "hardware_available": False,
+                "provider": state.led_provider,
+                "dw_led_num_leds": state.dw_led_num_leds,
+                "dw_led_gpio_pin": state.dw_led_gpio_pin,
+                "dw_led_pixel_order": state.dw_led_pixel_order,
+                "dw_led_brightness": state.dw_led_brightness
+            }
 
     else:  # none
         state.wled_ip = None
@@ -1731,6 +1792,51 @@ async def dw_leds_get_effect_settings():
         "playing_effect": state.dw_led_playing_effect
     }
 
+@app.post("/api/dw_leds/idle_timeout")
+async def dw_leds_set_idle_timeout(request: dict):
+    """Configure LED idle timeout settings"""
+    enabled = request.get("enabled", False)
+    minutes = request.get("minutes", 30)
+
+    # Validate minutes (between 1 and 1440 - 24 hours)
+    if minutes < 1 or minutes > 1440:
+        raise HTTPException(status_code=400, detail="Timeout must be between 1 and 1440 minutes")
+
+    state.dw_led_idle_timeout_enabled = enabled
+    state.dw_led_idle_timeout_minutes = minutes
+
+    # Reset activity time when settings change
+    import time
+    state.dw_led_last_activity_time = time.time()
+
+    state.save()
+    logger.info(f"DW LED idle timeout configured: enabled={enabled}, minutes={minutes}")
+
+    return {
+        "success": True,
+        "enabled": enabled,
+        "minutes": minutes
+    }
+
+@app.get("/api/dw_leds/idle_timeout")
+async def dw_leds_get_idle_timeout():
+    """Get LED idle timeout settings"""
+    import time
+
+    # Calculate remaining time if timeout is active
+    remaining_minutes = None
+    if state.dw_led_idle_timeout_enabled and state.dw_led_last_activity_time:
+        elapsed_seconds = time.time() - state.dw_led_last_activity_time
+        timeout_seconds = state.dw_led_idle_timeout_minutes * 60
+        remaining_seconds = max(0, timeout_seconds - elapsed_seconds)
+        remaining_minutes = round(remaining_seconds / 60, 1)
+
+    return {
+        "enabled": state.dw_led_idle_timeout_enabled,
+        "minutes": state.dw_led_idle_timeout_minutes,
+        "remaining_minutes": remaining_minutes
+    }
+
 @app.get("/table_control")
 async def table_control(request: Request):
     return templates.TemplateResponse("table_control.html", {"request": request, "app_name": state.app_name})

+ 10 - 1
modules/core/pattern_manager.py

@@ -629,6 +629,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
 
         state.current_playing_file = file_path
         state.stop_requested = False
+
+        # Reset LED idle timeout activity time when pattern starts
+        import time as time_module
+        state.dw_led_last_activity_time = time_module.time()
+
         logger.info(f"Starting pattern execution: {file_path}")
         logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
         await reset_theta()
@@ -760,7 +765,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
 async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
     """Run multiple .thr files in sequence with options."""
     state.stop_requested = False
-    
+
+    # Reset LED idle timeout activity time when playlist starts
+    import time as time_module
+    state.dw_led_last_activity_time = time_module.time()
+
     # Set initial playlist state
     state.playlist_mode = run_mode
     state.current_playlist_index = 0

+ 12 - 0
modules/core/state.py

@@ -56,6 +56,11 @@ class AppState:
 
         # Playing effect settings (all parameters)
         self.dw_led_playing_effect = None  # Full effect configuration dict or None
+
+        # Idle timeout settings
+        self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
+        self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
+        self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         self.skip_requested = False
         self.table_type = None
         self._playlist_mode = "loop"
@@ -214,6 +219,8 @@ class AppState:
             "dw_led_intensity": self.dw_led_intensity,
             "dw_led_idle_effect": self.dw_led_idle_effect,
             "dw_led_playing_effect": self.dw_led_playing_effect,
+            "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
+            "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
             "app_name": self.app_name,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
@@ -277,6 +284,11 @@ class AppState:
         else:
             # New format: full dict or None
             self.dw_led_playing_effect = playing_effect_data
+
+        # Load idle timeout settings
+        self.dw_led_idle_timeout_enabled = data.get('dw_led_idle_timeout_enabled', False)
+        self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
+
         self.app_name = data.get("app_name", "Dune Weaver")
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)

+ 123 - 0
static/js/led-control.js

@@ -307,6 +307,29 @@ async function initializeDWLedsControls() {
 
     // Load and display saved effect settings
     await loadEffectSettings();
+
+    // Idle timeout controls
+    await loadIdleTimeout();
+
+    const idleTimeoutEnabled = document.getElementById('dw-leds-idle-timeout-enabled');
+    const idleTimeoutSettings = document.getElementById('idle-timeout-settings');
+
+    // Toggle idle timeout settings visibility
+    idleTimeoutEnabled?.addEventListener('change', (e) => {
+        if (e.target.checked) {
+            idleTimeoutSettings?.classList.remove('opacity-50', 'pointer-events-none');
+        } else {
+            idleTimeoutSettings?.classList.add('opacity-50', 'pointer-events-none');
+        }
+    });
+
+    // Save idle timeout settings
+    document.getElementById('dw-leds-save-idle-timeout')?.addEventListener('click', async () => {
+        await saveIdleTimeout();
+    });
+
+    // Update remaining time periodically
+    setInterval(updateIdleTimeoutRemaining, 60000); // Update every minute
 }
 
 // Save current LED settings as idle or playing effect
@@ -432,6 +455,106 @@ function formatEffectSettings(settings) {
     return parts.join(' | ');
 }
 
+// Load idle timeout settings
+async function loadIdleTimeout() {
+    try {
+        const response = await fetch('/api/dw_leds/idle_timeout');
+        if (!response.ok) return;
+
+        const data = await response.json();
+
+        const enabledCheckbox = document.getElementById('dw-leds-idle-timeout-enabled');
+        const minutesInput = document.getElementById('dw-leds-idle-timeout-minutes');
+        const idleTimeoutSettings = document.getElementById('idle-timeout-settings');
+
+        if (enabledCheckbox) {
+            enabledCheckbox.checked = data.enabled;
+        }
+
+        if (minutesInput) {
+            minutesInput.value = data.minutes;
+        }
+
+        // Set initial state of settings panel
+        if (data.enabled) {
+            idleTimeoutSettings?.classList.remove('opacity-50', 'pointer-events-none');
+        } else {
+            idleTimeoutSettings?.classList.add('opacity-50', 'pointer-events-none');
+        }
+
+        // Update remaining time display
+        updateIdleTimeoutRemainingDisplay(data.remaining_minutes);
+    } catch (error) {
+        console.error('Failed to load idle timeout settings:', error);
+    }
+}
+
+// Save idle timeout settings
+async function saveIdleTimeout() {
+    try {
+        const enabled = document.getElementById('dw-leds-idle-timeout-enabled')?.checked || false;
+        const minutes = parseInt(document.getElementById('dw-leds-idle-timeout-minutes')?.value) || 30;
+
+        const response = await fetch('/api/dw_leds/idle_timeout', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ enabled, minutes })
+        });
+
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        const data = await response.json();
+
+        if (data.success) {
+            showStatus(`Idle timeout ${enabled ? 'enabled' : 'disabled'} (${minutes} minutes)`, 'success');
+            await loadIdleTimeout(); // Reload to get updated remaining time
+        } else {
+            showStatus('Failed to save idle timeout settings', 'error');
+        }
+    } catch (error) {
+        showStatus(`Failed to save idle timeout: ${error.message}`, 'error');
+    }
+}
+
+// Update idle timeout remaining time
+async function updateIdleTimeoutRemaining() {
+    try {
+        const response = await fetch('/api/dw_leds/idle_timeout');
+        if (!response.ok) return;
+
+        const data = await response.json();
+        updateIdleTimeoutRemainingDisplay(data.remaining_minutes);
+    } catch (error) {
+        console.error('Failed to update idle timeout remaining:', error);
+    }
+}
+
+// Update idle timeout remaining time display
+function updateIdleTimeoutRemainingDisplay(remainingMinutes) {
+    const remainingDiv = document.getElementById('idle-timeout-remaining');
+    const remainingDisplay = document.getElementById('idle-timeout-remaining-display');
+
+    if (!remainingDiv || !remainingDisplay) return;
+
+    if (remainingMinutes !== null && remainingMinutes !== undefined) {
+        remainingDiv.classList.remove('hidden');
+        if (remainingMinutes <= 0) {
+            remainingDisplay.textContent = 'Timeout expired - LEDs will turn off';
+        } else if (remainingMinutes < 1) {
+            remainingDisplay.textContent = 'Less than 1 minute';
+        } else {
+            const hours = Math.floor(remainingMinutes / 60);
+            const mins = Math.round(remainingMinutes % 60);
+            if (hours > 0) {
+                remainingDisplay.textContent = `${hours}h ${mins}m`;
+            } else {
+                remainingDisplay.textContent = `${mins} minutes`;
+            }
+        }
+    } else {
+        remainingDiv.classList.add('hidden');
+    }
+}
+
 // Helper function to apply color
 async function applyColor(hexColor) {
     try {

+ 12 - 1
static/js/settings.js

@@ -504,7 +504,18 @@ function setupEventListeners() {
                         localStorage.setItem('wled_ip', data.wled_ip);
                         showStatusMessage('WLED configured successfully', 'success');
                     } else if (provider === 'dw_leds') {
-                        showStatusMessage(`DW LEDs configured: ${data.dw_led_num_leds} LEDs on GPIO${data.dw_led_gpio_pin}`, 'success');
+                        // Check if there's a warning (hardware not available but settings saved)
+                        if (data.warning) {
+                            showStatusMessage(
+                                `Settings saved for testing. Hardware issue: ${data.warning}`,
+                                'warning'
+                            );
+                        } else {
+                            showStatusMessage(
+                                `DW LEDs configured: ${data.dw_led_num_leds} LEDs on GPIO${data.dw_led_gpio_pin}`,
+                                'success'
+                            );
+                        }
                     } else if (provider === 'none') {
                         localStorage.removeItem('wled_ip');
                         showStatusMessage('LED controller disabled', 'success');

+ 62 - 22
templates/led.html

@@ -259,51 +259,91 @@
           <p class="text-xs text-slate-500 mt-1">Configure LED effects to automatically activate when idle or playing patterns</p>
         </div>
 
-        <!-- Idle Effect Configuration -->
+        <!-- Playing Effect Configuration -->
         <div class="bg-slate-50 rounded-lg p-4 border border-slate-200">
           <div class="flex items-center justify-between mb-3">
             <h4 class="text-slate-700 text-sm font-semibold flex items-center gap-2">
-              <span class="material-icons text-blue-600 text-lg">bedtime</span>
-              Idle Effect
+              <span class="material-icons text-green-600 text-lg">play_circle</span>
+              Playing Effect
             </h4>
             <div class="flex gap-2">
-              <button id="dw-leds-save-current-idle" class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400">
+              <button id="dw-leds-save-current-playing" class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400">
                 <span class="material-icons text-sm">save</span>
                 Save Current
               </button>
-              <button id="dw-leds-clear-idle" class="flex items-center gap-1.5 rounded-lg bg-gray-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
+              <button id="dw-leds-clear-playing" class="flex items-center gap-1.5 rounded-lg bg-gray-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
                 <span class="material-icons text-sm">clear</span>
                 Clear
               </button>
             </div>
           </div>
 
-          <div id="idle-current-settings" class="text-xs text-slate-600 mb-3 p-2 bg-white rounded border border-slate-200">
-            <span class="font-medium">Current:</span> <span id="idle-settings-display">Not configured</span>
+          <div id="playing-current-settings" class="text-xs text-slate-600 p-2 bg-white rounded border border-slate-200">
+            <span class="font-medium">Current:</span> <span id="playing-settings-display">Not configured</span>
           </div>
         </div>
 
-        <!-- Playing Effect Configuration -->
+        <!-- Idle Configuration (Effect + Timeout) -->
         <div class="bg-slate-50 rounded-lg p-4 border border-slate-200">
-          <div class="flex items-center justify-between mb-3">
+          <div class="flex items-center justify-between mb-4">
             <h4 class="text-slate-700 text-sm font-semibold flex items-center gap-2">
-              <span class="material-icons text-blue-600 text-lg">play_circle</span>
-              Playing Effect
+              <span class="material-icons text-blue-600 text-lg">bedtime</span>
+              Idle Configuration
             </h4>
-            <div class="flex gap-2">
-              <button id="dw-leds-save-current-playing" class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400">
-                <span class="material-icons text-sm">save</span>
-                Save Current
-              </button>
-              <button id="dw-leds-clear-playing" class="flex items-center gap-1.5 rounded-lg bg-gray-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
-                <span class="material-icons text-sm">clear</span>
-                Clear
-              </button>
+          </div>
+
+          <!-- Idle Effect -->
+          <div class="mb-4 pb-4 border-b border-slate-200">
+            <div class="flex items-center justify-between mb-3">
+              <label class="text-xs font-medium text-slate-600">Idle Effect</label>
+              <div class="flex gap-2">
+                <button id="dw-leds-save-current-idle" class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400">
+                  <span class="material-icons text-sm">save</span>
+                  Save Current
+                </button>
+                <button id="dw-leds-clear-idle" class="flex items-center gap-1.5 rounded-lg bg-gray-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
+                  <span class="material-icons text-sm">clear</span>
+                  Clear
+                </button>
+              </div>
+            </div>
+            <div id="idle-current-settings" class="text-xs text-slate-600 p-2 bg-white rounded border border-slate-200">
+              <span class="font-medium">Current:</span> <span id="idle-settings-display">Not configured</span>
             </div>
           </div>
 
-          <div id="playing-current-settings" class="text-xs text-slate-600 mb-3 p-2 bg-white rounded border border-slate-200">
-            <span class="font-medium">Current:</span> <span id="playing-settings-display">Not configured</span>
+          <!-- Idle Timeout -->
+          <div>
+            <div class="flex items-center justify-between mb-3">
+              <label class="text-xs font-medium text-slate-600 flex items-center gap-2">
+                <span class="material-icons text-blue-600 text-base">schedule</span>
+                Auto Turn Off
+              </label>
+              <label class="relative inline-flex items-center cursor-pointer">
+                <input type="checkbox" id="dw-leds-idle-timeout-enabled" class="sr-only peer">
+                <div class="w-11 h-6 bg-slate-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
+              </label>
+            </div>
+
+            <div id="idle-timeout-settings" class="space-y-3">
+              <div>
+                <label class="block text-xs text-slate-500 mb-2">Turn off LEDs after</label>
+                <div class="flex items-center gap-3">
+                  <input type="number" id="dw-leds-idle-timeout-minutes" min="1" max="1440" value="30"
+                         class="flex-1 rounded-lg border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
+                  <span class="text-sm text-slate-600 whitespace-nowrap">minutes idle</span>
+                </div>
+              </div>
+
+              <div id="idle-timeout-remaining" class="text-xs text-slate-600 p-2 bg-white rounded border border-slate-200 hidden">
+                <span class="font-medium">Time remaining:</span> <span id="idle-timeout-remaining-display">--</span>
+              </div>
+
+              <button id="dw-leds-save-idle-timeout" class="w-full flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400">
+                <span class="material-icons text-base">save</span>
+                Save Timeout Settings
+              </button>
+            </div>
           </div>
         </div>