Преглед на файлове

Add LED control mode: Manual/HA vs DW Automated

Users controlling LEDs via Home Assistant or manually can now prevent
Dune Weaver from overriding their settings. Manual mode disables
auto-switching effects on play/idle, idle timeout, and Still Sands
turn-on (turn-off still works). Defaults to "automated" for backward
compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris преди 4 месеца
родител
ревизия
9ef843e3af
променени са 4 файла, в които са добавени 129 реда и са изтрити 23 реда
  1. 92 13
      frontend/src/pages/LEDPage.tsx
  2. 18 5
      main.py
  3. 12 5
      modules/core/pattern_manager.py
  4. 7 0
      modules/core/state.py

+ 92 - 13
frontend/src/pages/LEDPage.tsx

@@ -78,6 +78,9 @@ export function LEDPage() {
   // Ref for debouncing color picker API calls
   const colorDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
+  // LED control mode
+  const [controlMode, setControlMode] = useState<'manual' | 'automated'>('automated')
+
   // Effect automation state
   const [idleEffect, setIdleEffect] = useState<EffectSettings | null>(null)
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
@@ -89,14 +92,20 @@ export function LEDPage() {
   useEffect(() => {
     const fetchConfig = async () => {
       try {
-        const data = await apiClient.get<{ provider?: string; wled_ip?: string; dw_led_num_leds?: number; dw_led_gpio_pin?: number }>('/get_led_config')
+        const [configData, settingsData] = await Promise.all([
+          apiClient.get<{ provider?: string; wled_ip?: string; dw_led_num_leds?: number; dw_led_gpio_pin?: number }>('/get_led_config'),
+          apiClient.get<{ led?: { control_mode?: string } }>('/api/settings'),
+        ])
         // Map backend response fields to our interface
         setLedConfig({
-          provider: (data.provider as LedConfig['provider']) || 'none',
-          wled_ip: data.wled_ip,
-          num_leds: data.dw_led_num_leds,
-          gpio_pin: data.dw_led_gpio_pin,
+          provider: (configData.provider as LedConfig['provider']) || 'none',
+          wled_ip: configData.wled_ip,
+          num_leds: configData.dw_led_num_leds,
+          gpio_pin: configData.dw_led_gpio_pin,
         })
+        if (settingsData.led?.control_mode) {
+          setControlMode(settingsData.led.control_mode as 'manual' | 'automated')
+        }
       } catch (error) {
         console.error('Error fetching LED config:', error)
       } finally {
@@ -180,6 +189,16 @@ export function LEDPage() {
     }
   }
 
+  const handleControlModeChange = async (mode: 'manual' | 'automated') => {
+    setControlMode(mode)
+    try {
+      await apiClient.patch('/api/settings', { led: { control_mode: mode } })
+      toast.success(mode === 'manual' ? 'Manual / HA mode' : 'DW Automated mode')
+    } catch {
+      toast.error('Failed to update control mode')
+    }
+  }
+
   const handlePowerToggle = async () => {
     try {
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
@@ -386,15 +405,69 @@ export function LEDPage() {
     )
   }
 
+  // Mode selector card (shared between WLED and DW LEDs views)
+  const modeSelector = (
+    <Card>
+      <CardHeader className="pb-3">
+        <CardTitle className="text-lg flex items-center gap-2">
+          <span className="material-icons-outlined text-muted-foreground">tune</span>
+          LED Control Mode
+        </CardTitle>
+        <CardDescription>
+          Choose how Dune Weaver manages LED effects during playback and idle states
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+          <button
+            onClick={() => handleControlModeChange('manual')}
+            className={`p-4 rounded-lg border-2 text-left transition-all ${
+              controlMode === 'manual'
+                ? 'border-primary bg-primary/5'
+                : 'border-border hover:border-muted-foreground/30'
+            }`}
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <span className="material-icons-outlined text-base">front_hand</span>
+              <span className="font-medium text-sm">Manual / Home Assistant</span>
+            </div>
+            <p className="text-xs text-muted-foreground">
+              Effects persist until changed. Still Sands turns off only.
+            </p>
+          </button>
+          <button
+            onClick={() => handleControlModeChange('automated')}
+            className={`p-4 rounded-lg border-2 text-left transition-all ${
+              controlMode === 'automated'
+                ? 'border-primary bg-primary/5'
+                : 'border-border hover:border-muted-foreground/30'
+            }`}
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <span className="material-icons-outlined text-base">smart_toy</span>
+              <span className="font-medium text-sm">DW Automated</span>
+            </div>
+            <p className="text-xs text-muted-foreground">
+              Auto-switch effects on play/idle. Still Sands turns off and on.
+            </p>
+          </button>
+        </div>
+      </CardContent>
+    </Card>
+  )
+
   // WLED iframe view
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
     return (
-      <div className="flex flex-col w-full py-4" style={{ height: 'calc(100vh - 180px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
-        <iframe
-          src={`http://${ledConfig.wled_ip}`}
-          className="w-full h-full rounded-lg border border-border"
-          title="WLED Control"
-        />
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 py-3 sm:py-6 px-0 sm:px-4">
+        {modeSelector}
+        <div style={{ height: 'calc(100vh - 380px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
+          <iframe
+            src={`http://${ledConfig.wled_ip}`}
+            className="w-full h-full rounded-lg border border-border"
+            title="WLED Control"
+          />
+        </div>
       </div>
     )
   }
@@ -410,6 +483,8 @@ export function LEDPage() {
 
       <Separator />
 
+      {modeSelector}
+
       {/* Main Control Grid - 2 columns on large screens */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
         {/* Left Column - Primary Controls */}
@@ -636,7 +711,8 @@ export function LEDPage() {
             </CardContent>
           </Card>
 
-          {/* Auto Turn Off */}
+          {/* Auto Turn Off - hidden in manual mode */}
+          {controlMode === 'automated' && (
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
@@ -685,10 +761,12 @@ export function LEDPage() {
               )}
             </CardContent>
           </Card>
+          )}
         </div>
       </div>
 
-      {/* Automation Settings - Full Width */}
+      {/* Automation Settings - Full Width - hidden in manual mode */}
+      {controlMode === 'automated' && (
       <Card>
         <CardHeader className="pb-3">
           <CardTitle className="text-lg flex items-center gap-2">
@@ -763,6 +841,7 @@ export function LEDPage() {
           </div>
         </CardContent>
       </Card>
+      )}
     </div>
   )
 }

+ 18 - 5
main.py

@@ -180,9 +180,12 @@ async def lifespan(app: FastAPI):
             # Initialize hardware and start idle effect (matches behavior of /set_led_config)
             status = state.led_controller.check_status()
             if status.get("connected", False):
-                state.led_controller.effect_idle(state.dw_led_idle_effect)
-                _start_idle_led_timeout()
-                logger.info("DW LEDs hardware initialized and idle effect started")
+                if state.led_automation_enabled:
+                    state.led_controller.effect_idle(state.dw_led_idle_effect)
+                    _start_idle_led_timeout()
+                    logger.info("DW LEDs hardware initialized and idle effect started")
+                else:
+                    logger.info("DW LEDs hardware initialized (manual mode, no auto-effect)")
             else:
                 error_msg = status.get("error", "Unknown error")
                 logger.warning(f"DW LED hardware initialization failed: {error_msg}")
@@ -234,6 +237,9 @@ async def lifespan(app: FastAPI):
                 if not state.dw_led_idle_timeout_enabled:
                     continue
 
+                if not state.led_automation_enabled:
+                    continue
+
                 if not state.led_controller or not state.led_controller.is_configured:
                     continue
 
@@ -315,8 +321,11 @@ async def lifespan(app: FastAPI):
                         state.led_controller.set_power(0)
                 elif not in_still_sands and was_in_still_sands:
                     # Leaving Still Sands while idle — restore idle effect and restart timeout
-                    logger.info("Still Sands period ended while idle, restoring idle LED effect")
-                    await start_idle_led_timeout(check_still_sands=False)
+                    if state.led_automation_enabled:
+                        logger.info("Still Sands period ended while idle, restoring idle LED effect")
+                        await start_idle_led_timeout(check_still_sands=False)
+                    else:
+                        logger.info("Manual mode: Still Sands ended, LEDs remain off")
 
                 was_in_still_sands = in_still_sands
 
@@ -492,6 +501,7 @@ class DwLedSettingsUpdate(BaseModel):
 class LedSettingsUpdate(BaseModel):
     provider: Optional[str] = None  # "none", "wled", "dw_leds"
     wled_ip: Optional[str] = None
+    control_mode: Optional[str] = None  # "manual" or "automated"
     dw_led: Optional[DwLedSettingsUpdate] = None
 
 class MqttSettingsUpdate(BaseModel):
@@ -752,6 +762,7 @@ async def get_all_settings():
         "led": {
             "provider": state.led_provider,
             "wled_ip": state.wled_ip,
+            "control_mode": state.dw_led_control_mode,
             "dw_led": {
                 "num_leds": state.dw_led_num_leds,
                 "gpio_pin": state.dw_led_gpio_pin,
@@ -960,6 +971,8 @@ async def update_settings(settings_update: SettingsUpdate):
                 led_reinit_needed = True
         if led.wled_ip is not None:
             state.wled_ip = led.wled_ip or None
+        if led.control_mode is not None:
+            state.dw_led_control_mode = led.control_mode
         if led.dw_led:
             dw = led.dw_led
             if dw.num_leds is not None:

+ 12 - 5
modules/core/pattern_manager.py

@@ -353,6 +353,13 @@ async def start_idle_led_timeout(check_still_sands: bool = True):
     if not state.led_controller:
         return
 
+    if not state.led_automation_enabled:
+        # Manual mode: Still Sands can still turn OFF, but skip idle effect + timeout
+        if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
+            logger.info("Manual mode: Turning off LEDs during Still Sands period")
+            await state.led_controller.set_power_async(0)
+        return
+
     # Still Sands with LED control: turn off instead of idle effect
     if check_still_sands and is_in_scheduled_pause_period() and state.scheduled_pause_control_wled:
         logger.info("Turning off LED lights during Still Sands period")
@@ -1243,7 +1250,7 @@ async def _execute_pattern_internal(file_path):
     smoothed_rate = None  # For exponential smoothing of time-per-unit-weight rate
     # For WLED: always trigger (uses hardcoded preset 2)
     # For DW_LED: only trigger if effect is configured
-    if state.led_controller and (state.led_provider == "wled" or state.dw_led_playing_effect):
+    if state.led_controller and state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect):
         logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
         # Cancel idle timeout when playing starts
@@ -1356,7 +1363,7 @@ async def _execute_pattern_internal(file_path):
                 if state.led_controller:
                     # Always power LEDs back on if they were turned off for scheduled pause,
                     # regardless of whether a playing effect is configured
-                    if wled_was_off_for_scheduled:
+                    if wled_was_off_for_scheduled and state.led_automation_enabled:
                         logger.info("Turning LED lights back on as Still Sands period ended")
                         await state.led_controller.set_power_async(1)
                         # CRITICAL: Give LED controller time to fully power on before sending more commands
@@ -1365,7 +1372,7 @@ async def _execute_pattern_internal(file_path):
                     # Apply playing effect if configured
                     # For WLED: always trigger (uses hardcoded preset 2)
                     # For DW_LED: only trigger if effect is configured
-                    should_trigger_led = state.led_provider == "wled" or state.dw_led_playing_effect
+                    should_trigger_led = state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect)
                     if should_trigger_led:
                         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                     # Cancel idle timeout when resuming from pause
@@ -1614,14 +1621,14 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                         if state.led_controller:
                             # Always power LEDs back on if they were turned off for scheduled pause,
                             # regardless of whether a playing effect is configured
-                            if wled_was_off_for_scheduled:
+                            if wled_was_off_for_scheduled and state.led_automation_enabled:
                                 logger.info("Turning LED lights back on as Still Sands period ended")
                                 await state.led_controller.set_power_async(1)
                                 await asyncio.sleep(0.5)
                             # Apply playing effect if configured
                             # For WLED: always trigger (uses hardcoded preset 2)
                             # For DW_LED: only trigger if effect is configured
-                            should_trigger_led = state.led_provider == "wled" or state.dw_led_playing_effect
+                            should_trigger_led = state.led_automation_enabled and (state.led_provider == "wled" or state.dw_led_playing_effect)
                             if should_trigger_led:
                                 await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                             idle_timeout_manager.cancel_timeout()

+ 7 - 0
modules/core/state.py

@@ -115,6 +115,7 @@ class AppState:
         # 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_control_mode = "automated"  # "manual" or "automated"
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         self.table_type = None
         self.table_type_override = None  # User override for table type detection
@@ -442,6 +443,10 @@ class AppState:
             return 'skipped'
         return 'timeout'
 
+    @property
+    def led_automation_enabled(self) -> bool:
+        return self.dw_led_control_mode == "automated"
+
     @property
     def shuffle(self):
         return self._shuffle
@@ -508,6 +513,7 @@ class AppState:
             "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,
+            "dw_led_control_mode": self.dw_led_control_mode,
             "app_name": self.app_name,
             "table_id": self.table_id,
             "table_name": self.table_name,
@@ -624,6 +630,7 @@ class AppState:
 
         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.dw_led_control_mode = data.get('dw_led_control_mode', "automated")
 
         self.app_name = data.get("app_name", "Dune Weaver")
         self.table_id = data.get("table_id", None)