Explorar o código

Add still sand feature

tuanchris hai 4 meses
pai
achega
7ff4e0a995
Modificáronse 5 ficheiros con 641 adicións e 9 borrados
  1. 71 0
      main.py
  2. 71 6
      modules/core/pattern_manager.py
  3. 9 1
      modules/core/state.py
  4. 314 2
      static/js/settings.js
  5. 176 0
      templates/settings.html

+ 71 - 0
main.py

@@ -154,6 +154,16 @@ class auto_playModeRequest(BaseModel):
     clear_pattern: Optional[str] = "adaptive"
     clear_pattern: Optional[str] = "adaptive"
     shuffle: Optional[bool] = False
     shuffle: Optional[bool] = False
 
 
+class TimeSlot(BaseModel):
+    start_time: str  # HH:MM format
+    end_time: str    # HH:MM format
+    days: str        # "daily", "weekdays", "weekends", or "custom"
+    custom_days: Optional[List[str]] = []  # ["monday", "tuesday", etc.]
+
+class ScheduledPauseRequest(BaseModel):
+    enabled: bool
+    time_slots: List[TimeSlot] = []
+
 class CoordinateRequest(BaseModel):
 class CoordinateRequest(BaseModel):
     theta: float
     theta: float
     rho: float
     rho: float
@@ -305,6 +315,67 @@ async def set_auto_play_mode(request: auto_playModeRequest):
     logger.info(f"auto_play mode {'enabled' if request.enabled else 'disabled'}, playlist: {request.playlist}")
     logger.info(f"auto_play mode {'enabled' if request.enabled else 'disabled'}, playlist: {request.playlist}")
     return {"success": True, "message": "auto_play mode settings updated"}
     return {"success": True, "message": "auto_play mode settings updated"}
 
 
+@app.get("/api/scheduled-pause")
+async def get_scheduled_pause():
+    """Get current Still Sands settings."""
+    return {
+        "enabled": state.scheduled_pause_enabled,
+        "time_slots": state.scheduled_pause_time_slots
+    }
+
+@app.post("/api/scheduled-pause")
+async def set_scheduled_pause(request: ScheduledPauseRequest):
+    """Update Still Sands settings."""
+    try:
+        # Validate time slots
+        for i, slot in enumerate(request.time_slots):
+            # Validate time format (HH:MM)
+            try:
+                start_time = datetime.strptime(slot.start_time, "%H:%M").time()
+                end_time = datetime.strptime(slot.end_time, "%H:%M").time()
+            except ValueError:
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"Invalid time format in slot {i+1}. Use HH:MM format."
+                )
+
+            # Validate days setting
+            if slot.days not in ["daily", "weekdays", "weekends", "custom"]:
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"Invalid days setting in slot {i+1}. Must be 'daily', 'weekdays', 'weekends', or 'custom'."
+                )
+
+            # Validate custom days if applicable
+            if slot.days == "custom":
+                if not slot.custom_days or len(slot.custom_days) == 0:
+                    raise HTTPException(
+                        status_code=400,
+                        detail=f"Custom days must be specified for slot {i+1} when days is set to 'custom'."
+                    )
+
+                valid_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
+                for day in slot.custom_days:
+                    if day not in valid_days:
+                        raise HTTPException(
+                            status_code=400,
+                            detail=f"Invalid day '{day}' in slot {i+1}. Valid days are: {', '.join(valid_days)}"
+                        )
+
+        # Update state
+        state.scheduled_pause_enabled = request.enabled
+        state.scheduled_pause_time_slots = [slot.model_dump() for slot in request.time_slots]
+        state.save()
+
+        logger.info(f"Still Sands {'enabled' if request.enabled else 'disabled'} with {len(request.time_slots)} time slots")
+        return {"success": True, "message": "Still Sands settings updated"}
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Error updating Still Sands settings: {str(e)}")
+        raise HTTPException(status_code=500, detail=f"Failed to update Still Sands settings: {str(e)}")
+
 @app.get("/list_serial_ports")
 @app.get("/list_serial_ports")
 async def list_ports():
 async def list_ports():
     logger.debug("Listing available serial ports")
     logger.debug("Listing available serial ports")

+ 71 - 6
modules/core/pattern_manager.py

@@ -3,7 +3,7 @@ import threading
 import time
 import time
 import random
 import random
 import logging
 import logging
-from datetime import datetime
+from datetime import datetime, time as datetime_time
 from tqdm import tqdm
 from tqdm import tqdm
 from modules.connection import connection_manager
 from modules.connection import connection_manager
 from modules.core.state import state
 from modules.core.state import state
@@ -32,6 +32,53 @@ pattern_lock = asyncio.Lock()
 # Progress update task
 # Progress update task
 progress_update_task = None
 progress_update_task = None
 
 
+def is_in_scheduled_pause_period():
+    """Check if current time falls within any scheduled pause period."""
+    if not state.scheduled_pause_enabled or not state.scheduled_pause_time_slots:
+        return False
+
+    now = datetime.now()
+    current_time = now.time()
+    current_weekday = now.strftime("%A").lower()  # monday, tuesday, etc.
+
+    for slot in state.scheduled_pause_time_slots:
+        # Parse start and end times
+        try:
+            start_time = datetime_time.fromisoformat(slot['start_time'])
+            end_time = datetime_time.fromisoformat(slot['end_time'])
+        except (ValueError, KeyError):
+            logger.warning(f"Invalid time format in scheduled pause slot: {slot}")
+            continue
+
+        # Check if this slot applies to today
+        slot_applies_today = False
+        days_setting = slot.get('days', 'daily')
+
+        if days_setting == 'daily':
+            slot_applies_today = True
+        elif days_setting == 'weekdays':
+            slot_applies_today = current_weekday in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
+        elif days_setting == 'weekends':
+            slot_applies_today = current_weekday in ['saturday', 'sunday']
+        elif days_setting == 'custom':
+            custom_days = slot.get('custom_days', [])
+            slot_applies_today = current_weekday in custom_days
+
+        if not slot_applies_today:
+            continue
+
+        # Check if current time is within the pause period
+        if start_time <= end_time:
+            # Normal case: start and end are on the same day
+            if start_time <= current_time <= end_time:
+                return True
+        else:
+            # Time spans midnight: start is before midnight, end is after midnight
+            if current_time >= start_time or current_time <= end_time:
+                return True
+
+    return False
+
 # Motion Control Thread Infrastructure
 # Motion Control Thread Infrastructure
 @dataclass
 @dataclass
 class MotionCommand:
 class MotionCommand:
@@ -530,12 +577,28 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         effect_idle(state.led_controller)
                         effect_idle(state.led_controller)
                     break
                     break
 
 
-                # Wait for resume if paused
-                if state.pause_requested:
-                    logger.info("Execution paused...")
+                # Wait for resume if paused (manual or scheduled)
+                manual_pause = state.pause_requested
+                scheduled_pause = is_in_scheduled_pause_period()
+
+                if manual_pause or scheduled_pause:
+                    if manual_pause and scheduled_pause:
+                        logger.info("Execution paused (manual + scheduled pause active)...")
+                    elif manual_pause:
+                        logger.info("Execution paused (manual)...")
+                    else:
+                        logger.info("Execution paused (scheduled pause period)...")
+
                     if state.led_controller:
                     if state.led_controller:
                         effect_idle(state.led_controller)
                         effect_idle(state.led_controller)
-                    await pause_event.wait()
+
+                    # Wait until both manual pause is released AND we're outside scheduled pause period
+                    while state.pause_requested or is_in_scheduled_pause_period():
+                        await asyncio.sleep(1)  # Check every second
+                        # Also wait for the pause event in case of manual pause
+                        if state.pause_requested:
+                            await pause_event.wait()
+
                     logger.info("Execution resumed...")
                     logger.info("Execution resumed...")
                     if state.led_controller:
                     if state.led_controller:
                         effect_playing(state.led_controller)
                         effect_playing(state.led_controller)
@@ -804,7 +867,9 @@ def get_status():
     """Get the current status of pattern execution."""
     """Get the current status of pattern execution."""
     status = {
     status = {
         "current_file": state.current_playing_file,
         "current_file": state.current_playing_file,
-        "is_paused": state.pause_requested,
+        "is_paused": state.pause_requested or is_in_scheduled_pause_period(),
+        "manual_pause": state.pause_requested,
+        "scheduled_pause": is_in_scheduled_pause_period(),
         "is_running": bool(state.current_playing_file and not state.stop_requested),
         "is_running": bool(state.current_playing_file and not state.stop_requested),
         "progress": None,
         "progress": None,
         "playlist": None,
         "playlist": None,

+ 9 - 1
modules/core/state.py

@@ -56,10 +56,14 @@ class AppState:
         # auto_play mode settings
         # auto_play mode settings
         self.auto_play_enabled = False
         self.auto_play_enabled = False
         self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
         self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
-        self.auto_play_run_mode = "loop"  # "single" or "loop" 
+        self.auto_play_run_mode = "loop"  # "single" or "loop"
         self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
         self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
         self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
         self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
         self.auto_play_shuffle = False  # Shuffle playlist
         self.auto_play_shuffle = False  # Shuffle playlist
+
+        # Still Sands settings
+        self.scheduled_pause_enabled = False
+        self.scheduled_pause_time_slots = []  # List of time slot dictionaries
         
         
         self.load()
         self.load()
 
 
@@ -192,6 +196,8 @@ class AppState:
             "auto_play_pause_time": self.auto_play_pause_time,
             "auto_play_pause_time": self.auto_play_pause_time,
             "auto_play_clear_pattern": self.auto_play_clear_pattern,
             "auto_play_clear_pattern": self.auto_play_clear_pattern,
             "auto_play_shuffle": self.auto_play_shuffle,
             "auto_play_shuffle": self.auto_play_shuffle,
+            "scheduled_pause_enabled": self.scheduled_pause_enabled,
+            "scheduled_pause_time_slots": self.scheduled_pause_time_slots,
         }
         }
 
 
     def from_dict(self, data):
     def from_dict(self, data):
@@ -228,6 +234,8 @@ class AppState:
         self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
         self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
         self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
         self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
         self.auto_play_shuffle = data.get("auto_play_shuffle", False)
         self.auto_play_shuffle = data.get("auto_play_shuffle", False)
+        self.scheduled_pause_enabled = data.get("scheduled_pause_enabled", False)
+        self.scheduled_pause_time_slots = data.get("scheduled_pause_time_slots", [])
 
 
     def save(self):
     def save(self):
         """Save the current state to a JSON file."""
         """Save the current state to a JSON file."""

+ 314 - 2
static/js/settings.js

@@ -180,8 +180,11 @@ document.addEventListener('DOMContentLoaded', async () => {
         fetch('/api/clear_pattern_speed').then(response => response.json()).catch(() => ({ clear_pattern_speed: 200 })),
         fetch('/api/clear_pattern_speed').then(response => response.json()).catch(() => ({ clear_pattern_speed: 200 })),
         
         
         // Load current app name
         // Load current app name
-        fetch('/api/app-name').then(response => response.json()).catch(() => ({ app_name: 'Dune Weaver' }))
-    ]).then(([statusData, wledData, updateData, ports, patterns, clearPatterns, clearSpeedData, appNameData]) => {
+        fetch('/api/app-name').then(response => response.json()).catch(() => ({ app_name: 'Dune Weaver' })),
+
+        // Load Still Sands settings
+        fetch('/api/scheduled-pause').then(response => response.json()).catch(() => ({ enabled: false, time_slots: [] }))
+    ]).then(([statusData, wledData, updateData, ports, patterns, clearPatterns, clearSpeedData, appNameData, scheduledPauseData]) => {
         // Update connection status
         // Update connection status
         setCachedConnectionStatus(statusData);
         setCachedConnectionStatus(statusData);
         updateConnectionUI(statusData);
         updateConnectionUI(statusData);
@@ -299,6 +302,9 @@ document.addEventListener('DOMContentLoaded', async () => {
         if (appNameInput && appNameData.app_name) {
         if (appNameInput && appNameData.app_name) {
             appNameInput.value = appNameData.app_name;
             appNameInput.value = appNameData.app_name;
         }
         }
+
+        // Store Still Sands data for later initialization
+        window.initialStillSandsData = scheduledPauseData;
     }).catch(error => {
     }).catch(error => {
         logMessage(`Error initializing settings page: ${error.message}`, LOG_TYPE.ERROR);
         logMessage(`Error initializing settings page: ${error.message}`, LOG_TYPE.ERROR);
     });
     });
@@ -1020,4 +1026,310 @@ async function initializeauto_playMode() {
 // Initialize auto_play mode when DOM is ready
 // Initialize auto_play mode when DOM is ready
 document.addEventListener('DOMContentLoaded', function() {
 document.addEventListener('DOMContentLoaded', function() {
     initializeauto_playMode();
     initializeauto_playMode();
+    initializeStillSandsMode();
 });
 });
+
+// Still Sands Mode Functions
+async function initializeStillSandsMode() {
+    logMessage('Initializing Still Sands mode', LOG_TYPE.INFO);
+
+    const stillSandsToggle = document.getElementById('scheduledPauseToggle');
+    const stillSandsSettings = document.getElementById('scheduledPauseSettings');
+    const addTimeSlotButton = document.getElementById('addTimeSlotButton');
+    const saveStillSandsButton = document.getElementById('savePauseSettings');
+    const timeSlotsContainer = document.getElementById('timeSlotsContainer');
+
+    // Check if elements exist
+    if (!stillSandsToggle || !stillSandsSettings || !addTimeSlotButton || !saveStillSandsButton || !timeSlotsContainer) {
+        logMessage('Still Sands elements not found, skipping initialization', LOG_TYPE.WARNING);
+        logMessage(`Found elements: toggle=${!!stillSandsToggle}, settings=${!!stillSandsSettings}, addBtn=${!!addTimeSlotButton}, saveBtn=${!!saveStillSandsButton}, container=${!!timeSlotsContainer}`, LOG_TYPE.WARNING);
+        return;
+    }
+
+    logMessage('All Still Sands elements found successfully', LOG_TYPE.INFO);
+
+    // Track time slots
+    let timeSlots = [];
+    let slotIdCounter = 0;
+
+    // Load current Still Sands settings from initial data
+    try {
+        // Use the data loaded during page initialization, fallback to API if not available
+        let data;
+        if (window.initialStillSandsData) {
+            data = window.initialStillSandsData;
+            // Clear the global variable after use
+            delete window.initialStillSandsData;
+        } else {
+            // Fallback to API call if initial data not available
+            const response = await fetch('/api/scheduled-pause');
+            data = await response.json();
+        }
+
+        stillSandsToggle.checked = data.enabled || false;
+        if (data.enabled) {
+            stillSandsSettings.style.display = 'block';
+        }
+
+        // Load existing time slots
+        timeSlots = data.time_slots || [];
+        renderTimeSlots();
+    } catch (error) {
+        logMessage(`Error loading Still Sands settings: ${error.message}`, LOG_TYPE.ERROR);
+        // Initialize with empty settings if load fails
+        timeSlots = [];
+        renderTimeSlots();
+    }
+
+    // Function to validate time format (HH:MM)
+    function isValidTime(timeString) {
+        const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
+        return timeRegex.test(timeString);
+    }
+
+    // Function to create a new time slot element
+    function createTimeSlotElement(slot) {
+        const slotDiv = document.createElement('div');
+        slotDiv.className = 'time-slot-item';
+        slotDiv.dataset.slotId = slot.id;
+
+        slotDiv.innerHTML = `
+            <div class="flex items-center gap-3">
+                <div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3">
+                    <div class="flex flex-col gap-1">
+                        <label class="text-slate-700 dark:text-slate-300 text-xs font-medium">Start Time</label>
+                        <input
+                            type="time"
+                            class="start-time form-input resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-9 px-3 text-sm font-normal leading-normal transition-colors"
+                            value="${slot.start_time || ''}"
+                            required
+                        />
+                    </div>
+                    <div class="flex flex-col gap-1">
+                        <label class="text-slate-700 dark:text-slate-300 text-xs font-medium">End Time</label>
+                        <input
+                            type="time"
+                            class="end-time form-input resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-9 px-3 text-sm font-normal leading-normal transition-colors"
+                            value="${slot.end_time || ''}"
+                            required
+                        />
+                    </div>
+                </div>
+                <div class="flex flex-col gap-1">
+                    <label class="text-slate-700 dark:text-slate-300 text-xs font-medium">Days</label>
+                    <select class="days-select form-select resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-9 px-3 text-sm font-normal transition-colors">
+                        <option value="daily" ${slot.days === 'daily' ? 'selected' : ''}>Daily</option>
+                        <option value="weekdays" ${slot.days === 'weekdays' ? 'selected' : ''}>Weekdays</option>
+                        <option value="weekends" ${slot.days === 'weekends' ? 'selected' : ''}>Weekends</option>
+                        <option value="custom" ${slot.days === 'custom' ? 'selected' : ''}>Custom</option>
+                    </select>
+                </div>
+                <button
+                    type="button"
+                    class="remove-slot-btn flex items-center justify-center w-9 h-9 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
+                    title="Remove time slot"
+                >
+                    <span class="material-icons text-base">delete</span>
+                </button>
+            </div>
+            <div class="custom-days-container mt-2" style="display: ${slot.days === 'custom' ? 'block' : 'none'};">
+                <label class="text-slate-700 dark:text-slate-300 text-xs font-medium mb-1 block">Select Days</label>
+                <div class="flex flex-wrap gap-2">
+                    ${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => `
+                        <label class="flex items-center gap-1 text-xs">
+                            <input
+                                type="checkbox"
+                                name="custom-days-${slot.id}"
+                                value="${day}"
+                                ${slot.custom_days && slot.custom_days.includes(day) ? 'checked' : ''}
+                                class="rounded border-slate-300 text-sky-600 focus:ring-sky-500"
+                            />
+                            <span class="text-slate-700 dark:text-slate-300 capitalize">${day.substring(0, 3)}</span>
+                        </label>
+                    `).join('')}
+                </div>
+            </div>
+        `;
+
+        // Add event listeners for this slot
+        const startTimeInput = slotDiv.querySelector('.start-time');
+        const endTimeInput = slotDiv.querySelector('.end-time');
+        const daysSelect = slotDiv.querySelector('.days-select');
+        const customDaysContainer = slotDiv.querySelector('.custom-days-container');
+        const removeButton = slotDiv.querySelector('.remove-slot-btn');
+
+        // Show/hide custom days based on selection
+        daysSelect.addEventListener('change', () => {
+            customDaysContainer.style.display = daysSelect.value === 'custom' ? 'block' : 'none';
+            updateTimeSlot(slot.id);
+        });
+
+        // Update slot data when inputs change
+        startTimeInput.addEventListener('change', () => updateTimeSlot(slot.id));
+        endTimeInput.addEventListener('change', () => updateTimeSlot(slot.id));
+
+        // Handle custom day checkboxes
+        customDaysContainer.addEventListener('change', () => updateTimeSlot(slot.id));
+
+        // Remove slot button
+        removeButton.addEventListener('click', () => {
+            removeTimeSlot(slot.id);
+        });
+
+        return slotDiv;
+    }
+
+    // Function to render all time slots
+    function renderTimeSlots() {
+        timeSlotsContainer.innerHTML = '';
+
+        if (timeSlots.length === 0) {
+            timeSlotsContainer.innerHTML = `
+                <div class="text-center py-8 text-slate-500 dark:text-slate-400">
+                    <span class="material-icons text-4xl mb-2 block">schedule</span>
+                    <p>No time slots configured</p>
+                    <p class="text-xs mt-1">Click "Add Time Slot" to create a pause schedule</p>
+                </div>
+            `;
+            return;
+        }
+
+        timeSlots.forEach(slot => {
+            const slotElement = createTimeSlotElement(slot);
+            timeSlotsContainer.appendChild(slotElement);
+        });
+    }
+
+    // Function to add a new time slot
+    function addTimeSlot() {
+        const newSlot = {
+            id: ++slotIdCounter,
+            start_time: '22:00',
+            end_time: '08:00',
+            days: 'daily',
+            custom_days: []
+        };
+
+        timeSlots.push(newSlot);
+        renderTimeSlots();
+    }
+
+    // Function to remove a time slot
+    function removeTimeSlot(slotId) {
+        timeSlots = timeSlots.filter(slot => slot.id !== slotId);
+        renderTimeSlots();
+    }
+
+    // Function to update a time slot's data
+    function updateTimeSlot(slotId) {
+        const slotElement = timeSlotsContainer.querySelector(`[data-slot-id="${slotId}"]`);
+        if (!slotElement) return;
+
+        const slot = timeSlots.find(s => s.id === slotId);
+        if (!slot) return;
+
+        // Update slot data from inputs
+        slot.start_time = slotElement.querySelector('.start-time').value;
+        slot.end_time = slotElement.querySelector('.end-time').value;
+        slot.days = slotElement.querySelector('.days-select').value;
+
+        // Update custom days if applicable
+        if (slot.days === 'custom') {
+            const checkedDays = Array.from(slotElement.querySelectorAll(`input[name="custom-days-${slotId}"]:checked`))
+                .map(cb => cb.value);
+            slot.custom_days = checkedDays;
+        } else {
+            slot.custom_days = [];
+        }
+    }
+
+    // Function to validate all time slots
+    function validateTimeSlots() {
+        const errors = [];
+
+        timeSlots.forEach((slot, index) => {
+            if (!slot.start_time || !isValidTime(slot.start_time)) {
+                errors.push(`Time slot ${index + 1}: Invalid start time`);
+            }
+            if (!slot.end_time || !isValidTime(slot.end_time)) {
+                errors.push(`Time slot ${index + 1}: Invalid end time`);
+            }
+            if (slot.days === 'custom' && (!slot.custom_days || slot.custom_days.length === 0)) {
+                errors.push(`Time slot ${index + 1}: Please select at least one day for custom schedule`);
+            }
+        });
+
+        return errors;
+    }
+
+    // Function to save settings
+    async function saveStillSandsSettings() {
+        // Update all slots from current form values
+        timeSlots.forEach(slot => updateTimeSlot(slot.id));
+
+        // Validate time slots
+        const validationErrors = validateTimeSlots();
+        if (validationErrors.length > 0) {
+            showStatusMessage(`Validation errors: ${validationErrors.join(', ')}`, 'error');
+            return;
+        }
+
+        try {
+            const response = await fetch('/api/scheduled-pause', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({
+                    enabled: stillSandsToggle.checked,
+                    time_slots: timeSlots.map(slot => ({
+                        start_time: slot.start_time,
+                        end_time: slot.end_time,
+                        days: slot.days,
+                        custom_days: slot.custom_days
+                    }))
+                })
+            });
+
+            if (!response.ok) {
+                const errorData = await response.json();
+                throw new Error(errorData.detail || 'Failed to save Still Sands settings');
+            }
+
+            showStatusMessage('Still Sands settings saved successfully', 'success');
+        } catch (error) {
+            logMessage(`Error saving Still Sands settings: ${error.message}`, LOG_TYPE.ERROR);
+            showStatusMessage(`Failed to save settings: ${error.message}`, 'error');
+        }
+    }
+
+    // Initialize slot ID counter
+    if (timeSlots.length > 0) {
+        slotIdCounter = Math.max(...timeSlots.map(slot => slot.id || 0));
+    }
+
+    // Assign IDs to existing slots if they don't have them
+    timeSlots.forEach(slot => {
+        if (!slot.id) {
+            slot.id = ++slotIdCounter;
+        }
+    });
+
+    // Event listeners
+    stillSandsToggle.addEventListener('change', async () => {
+        logMessage(`Still Sands toggle changed: ${stillSandsToggle.checked}`, LOG_TYPE.INFO);
+        stillSandsSettings.style.display = stillSandsToggle.checked ? 'block' : 'none';
+        logMessage(`Settings display set to: ${stillSandsSettings.style.display}`, LOG_TYPE.INFO);
+
+        // Auto-save when toggle changes
+        try {
+            await saveStillSandsSettings();
+            const statusText = stillSandsToggle.checked ? 'enabled' : 'disabled';
+            showStatusMessage(`Still Sands ${statusText} successfully`, 'success');
+        } catch (error) {
+            logMessage(`Error saving Still Sands toggle: ${error.message}`, LOG_TYPE.ERROR);
+            showStatusMessage(`Failed to save Still Sands setting: ${error.message}`, 'error');
+        }
+    });
+
+    addTimeSlotButton.addEventListener('click', addTimeSlot);
+    saveStillSandsButton.addEventListener('click', saveStillSandsSettings);
+}

+ 176 - 0
templates/settings.html

@@ -112,6 +112,114 @@ endblock %}
   background-color: #92400e;
   background-color: #92400e;
   color: #fef3c7;
   color: #fef3c7;
 }
 }
+
+/* Toggle switch styles */
+.switch {
+  position: relative;
+  display: inline-block;
+  width: 60px;
+  height: 34px;
+}
+
+.switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+}
+
+.slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: #ccc;
+  transition: .4s;
+}
+
+.slider:before {
+  position: absolute;
+  content: "";
+  height: 26px;
+  width: 26px;
+  left: 4px;
+  bottom: 4px;
+  background-color: white;
+  transition: .4s;
+}
+
+input:checked + .slider {
+  background-color: #0c7ff2;
+}
+
+input:focus + .slider {
+  box-shadow: 0 0 1px #0c7ff2;
+}
+
+input:checked + .slider:before {
+  transform: translateX(26px);
+}
+
+.slider.round {
+  border-radius: 34px;
+}
+
+.slider.round:before {
+  border-radius: 50%;
+}
+
+/* Dark mode for switches */
+.dark .slider {
+  background-color: #404040;
+}
+
+.dark input:checked + .slider {
+  background-color: #0c7ff2;
+}
+
+/* Time slot specific styles */
+.time-slot-item {
+  background-color: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 16px;
+  transition: all 0.15s;
+}
+
+.dark .time-slot-item {
+  background-color: #1e293b;
+  border-color: #475569;
+}
+
+.time-slot-item:hover {
+  border-color: #cbd5e1;
+}
+
+.dark .time-slot-item:hover {
+  border-color: #64748b;
+}
+
+/* Info box dark mode */
+.dark .bg-blue-50 {
+  background-color: #1e3a8a;
+}
+
+.dark .border-blue-200 {
+  border-color: #1e40af;
+}
+
+.dark .text-blue-600 {
+  color: #60a5fa;
+}
+
+.dark .text-blue-800 {
+  color: #dbeafe;
+}
+
+.dark .text-blue-700 {
+  color: #bfdbfe;
+}
 {% endblock %}
 {% endblock %}
 
 
 {% block content %}
 {% block content %}
@@ -488,6 +596,74 @@ endblock %}
       </div>
       </div>
     </div>
     </div>
   </section>
   </section>
+  <section class="bg-white rounded-xl shadow-sm overflow-hidden">
+    <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"
+    >
+      Still Sands
+    </h2>
+    <div class="px-6 py-5 space-y-6">
+      <div class="flex items-center justify-between">
+        <div class="flex-1">
+          <h3 class="text-slate-700 text-base font-medium leading-normal">Enable Still Sands</h3>
+          <p class="text-xs text-slate-500 mt-1">
+            Automatically bring the sands to rest during specified time periods.
+          </p>
+        </div>
+        <label class="switch">
+          <input type="checkbox" id="scheduledPauseToggle">
+          <span class="slider round"></span>
+        </label>
+      </div>
+
+      <div id="scheduledPauseSettings" class="space-y-4" style="display: none;">
+        <div class="bg-slate-50 rounded-lg p-4 space-y-4">
+          <div class="flex items-center justify-between">
+            <h4 class="text-slate-800 text-base font-semibold">Still Periods</h4>
+            <button
+              id="addTimeSlotButton"
+              class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-9 px-3 bg-sky-600 hover:bg-sky-700 text-white text-xs font-medium leading-normal tracking-[0.015em] transition-colors"
+            >
+              <span class="material-icons text-base">add</span>
+              <span>Add Still Period</span>
+            </button>
+          </div>
+          <p class="text-sm text-slate-600">
+            Define time periods when the sands should rest in stillness. Patterns will resume automatically when still periods end.
+          </p>
+
+          <div id="timeSlotsContainer" class="space-y-3">
+            <!-- Time slots will be dynamically added here -->
+          </div>
+
+          <div class="text-xs text-slate-500 bg-blue-50 border border-blue-200 rounded-lg p-3">
+            <div class="flex items-start gap-2">
+              <span class="material-icons text-blue-600 text-base">info</span>
+              <div>
+                <p class="font-medium text-blue-800">Important Notes:</p>
+                <ul class="mt-1 space-y-1 text-blue-700">
+                  <li>• Times are based on your system's local time zone</li>
+                  <li>• Currently running patterns will pause immediately when entering a still period</li>
+                  <li>• Patterns will resume automatically when exiting a still period</li>
+                  <li>• Still periods that span midnight (e.g., 22:00 to 06:00) are supported</li>
+                </ul>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="flex justify-end">
+          <button
+            id="savePauseSettings"
+            class="flex items-center justify-center gap-2 min-w-[140px] cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors"
+          >
+            <span class="material-icons text-lg">save</span>
+            <span class="truncate">Save Still Sands</span>
+          </button>
+        </div>
+      </div>
+    </div>
+  </section>
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
     <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"