Explorar el Código

release: v4.1.4 — serial resilience, scheduled reboot, MQTT light fix

- pattern_manager: treat FluidNC extended errors 170-181 (expression/
  flow-control parser) as serial corruption and retry the command
  instead of stopping the pattern; fixes intermittent pattern aborts
  from UART line noise (e.g. error:176 on Pi 3B+)
- scheduled reboot: new setting to reboot the host board daily at a
  set time; defers while a pattern is drawing, uses Still Sands timezone
- mqtt: fix HA LED Color light registering without a color picker —
  declare supported_color_modes for the JSON schema and align
  command/state payloads (nested color object, on/off state)
- main: add_to_queue inserted the bare filename instead of the
  THETA_RHO_DIR-joined path, so queued patterns were silently
  skipped and only clearing patterns ran (#130)
- requirements-nonrpi: add pyyaml, required by FluidNC config (#126)
- playlists: per-day pacing option — N plays per day with the
  interval timed from pattern start (pause_from_start)
- bump VERSION to 4.1.4, rebuild frontend bundle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tuanchris hace 1 mes
padre
commit
c4917ace3f

+ 1 - 1
VERSION

@@ -1 +1 @@
-4.1.3
+4.1.4

+ 26 - 9
frontend/src/pages/PlaylistsPage.tsx

@@ -102,9 +102,9 @@ export function PlaylistsPage() {
     const cached = localStorage.getItem('playlist-pauseTime')
     const cached = localStorage.getItem('playlist-pauseTime')
     return cached ? Number(cached) : 5
     return cached ? Number(cached) : 5
   })
   })
-  const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>(() => {
+  const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr' | 'per_day'>(() => {
     const cached = localStorage.getItem('playlist-pauseUnit')
     const cached = localStorage.getItem('playlist-pauseUnit')
-    return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
+    return (cached === 'sec' || cached === 'min' || cached === 'hr' || cached === 'per_day') ? cached : 'min'
   })
   })
   const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
   const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
     const cached = localStorage.getItem('preExecution')
     const cached = localStorage.getItem('preExecution')
@@ -168,9 +168,13 @@ export function PlaylistsPage() {
   }, [])
   }, [])
   const [isRunning, setIsRunning] = useState(false)
   const [isRunning, setIsRunning] = useState(false)
 
 
-  // Convert pause time to seconds based on unit
+  // Convert pause time to seconds based on unit.
+  // For 'per_day', value is "plays per day" so the interval between starts
+  // is 86400 / N seconds.
   const getPauseTimeInSeconds = () => {
   const getPauseTimeInSeconds = () => {
     switch (pauseUnit) {
     switch (pauseUnit) {
+      case 'per_day':
+        return Math.floor(86400 / Math.max(1, pauseTime))
       case 'hr':
       case 'hr':
         return pauseTime * 3600
         return pauseTime * 3600
       case 'min':
       case 'min':
@@ -462,6 +466,7 @@ export function PlaylistsPage() {
         pause_time: getPauseTimeInSeconds(),
         pause_time: getPauseTimeInSeconds(),
         clear_pattern: clearPattern,
         clear_pattern: clearPattern,
         shuffle: shuffle,
         shuffle: shuffle,
+        pause_from_start: pauseUnit === 'per_day',
       })
       })
       toast.success(`Started playlist: ${selectedPlaylist}`)
       toast.success(`Started playlist: ${selectedPlaylist}`)
       // Trigger Now Playing bar to open
       // Trigger Now Playing bar to open
@@ -800,21 +805,30 @@ export function PlaylistsPage() {
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         onClick={() => {
                         onClick={() => {
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           const step = pauseUnit === 'hr' ? 0.5 : 1
-                          setPauseTime(Math.max(0, pauseTime - step))
+                          // 'per_day' is plays-per-day, so it must stay >= 1.
+                          const min = pauseUnit === 'per_day' ? 1 : 0
+                          setPauseTime(Math.max(min, pauseTime - step))
                         }}
                         }}
                       >
                       >
                         <span className="material-icons-outlined text-sm">remove</span>
                         <span className="material-icons-outlined text-sm">remove</span>
                       </Button>
                       </Button>
                       <button
                       <button
                         onClick={() => {
                         onClick={() => {
-                          const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
+                          const units: ('sec' | 'min' | 'hr' | 'per_day')[] = ['sec', 'min', 'hr', 'per_day']
                           const currentIndex = units.indexOf(pauseUnit)
                           const currentIndex = units.indexOf(pauseUnit)
-                          setPauseUnit(units[(currentIndex + 1) % units.length])
+                          const nextUnit = units[(currentIndex + 1) % units.length]
+                          // Switching into per_day with a 0 value would mean "0 plays/day" — bump to 1.
+                          if (nextUnit === 'per_day' && pauseTime < 1) {
+                            setPauseTime(1)
+                          }
+                          setPauseUnit(nextUnit)
                         }}
                         }}
                         className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
                         className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
-                        title="Click to change unit"
+                        title={pauseUnit === 'per_day' ? 'Plays per day (timed from pattern start). Click to change unit.' : 'Click to change unit'}
                       >
                       >
-                        {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
+                        {pauseUnit === 'per_day'
+                          ? `${pauseTime}/day`
+                          : `${pauseTime}${pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}`}
                         <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
                         <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
                       </button>
                       </button>
                       <Button
                       <Button
@@ -823,7 +837,10 @@ export function PlaylistsPage() {
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         onClick={() => {
                         onClick={() => {
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           const step = pauseUnit === 'hr' ? 0.5 : 1
-                          setPauseTime(pauseTime + step)
+                          // Cap per_day at 24 (one start every hour) — beyond that the cadence
+                          // collides with typical pattern run durations.
+                          const max = pauseUnit === 'per_day' ? 24 : Number.POSITIVE_INFINITY
+                          setPauseTime(Math.min(max, pauseTime + step))
                         }}
                         }}
                       >
                       >
                         <span className="material-icons-outlined text-sm">add</span>
                         <span className="material-icons-outlined text-sm">add</span>

+ 109 - 0
frontend/src/pages/SettingsPage.tsx

@@ -171,6 +171,12 @@ export function SettingsPage() {
     time_slots: [],
     time_slots: [],
   })
   })
 
 
+  // Scheduled reboot state
+  const [rebootSettings, setRebootSettings] = useState<{ enabled: boolean; time: string }>({
+    enabled: false,
+    time: '03:00',
+  })
+
   // Pattern search state for clearing patterns
   // Pattern search state for clearing patterns
   const [patternFiles, setPatternFiles] = useState<string[]>([])
   const [patternFiles, setPatternFiles] = useState<string[]>([])
 
 
@@ -397,6 +403,13 @@ export function SettingsPage() {
           time_slots: data.scheduled_pause.time_slots || [],
           time_slots: data.scheduled_pause.time_slots || [],
         })
         })
       }
       }
+      // Set scheduled reboot settings
+      if (data.scheduled_reboot) {
+        setRebootSettings({
+          enabled: data.scheduled_reboot.enabled || false,
+          time: data.scheduled_reboot.time || '03:00',
+        })
+      }
       // Set security settings
       // Set security settings
       if (data.security) {
       if (data.security) {
         setSecurityMode(data.security.mode || 'off')
         setSecurityMode(data.security.mode || 'off')
@@ -732,6 +745,20 @@ export function SettingsPage() {
     }
     }
   }
   }
 
 
+  const handleSaveRebootSettings = async () => {
+    setIsLoading('reboot')
+    try {
+      await apiClient.patch('/api/settings', {
+        scheduled_reboot: rebootSettings,
+      })
+      toast.success('Scheduled reboot settings saved')
+    } catch (error) {
+      toast.error('Failed to save scheduled reboot settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
   const addTimeSlot = () => {
   const addTimeSlot = () => {
     setStillSandsSettings({
     setStillSandsSettings({
       ...stillSandsSettings,
       ...stillSandsSettings,
@@ -2275,6 +2302,88 @@ export function SettingsPage() {
           </AccordionContent>
           </AccordionContent>
         </AccordionItem>
         </AccordionItem>
 
 
+        {/* Scheduled Reboot */}
+        <AccordionItem value="reboot" id="section-reboot" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                restart_alt
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Scheduled Reboot</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Reboot the controller board daily at a set time
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            <div className="flex items-center justify-between p-4 rounded-lg border">
+              <div>
+                <p className="font-medium">Enable Scheduled Reboot</p>
+                <p className="text-sm text-muted-foreground">
+                  Reboot the board (e.g. Raspberry Pi) once a day
+                </p>
+              </div>
+              <Switch
+                checked={rebootSettings.enabled}
+                onCheckedChange={(checked) =>
+                  setRebootSettings({ ...rebootSettings, enabled: checked })
+                }
+              />
+            </div>
+
+            {rebootSettings.enabled && (
+              <div className="space-y-3">
+                <div className="p-4 rounded-lg border">
+                  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
+                    <div className="flex items-center gap-3">
+                      <span className="material-icons-outlined text-muted-foreground">
+                        schedule
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Reboot Time</p>
+                        <p className="text-xs text-muted-foreground">
+                          Uses the timezone selected in Still Sands (or system default)
+                        </p>
+                      </div>
+                    </div>
+                    <Input
+                      type="time"
+                      value={rebootSettings.time}
+                      onChange={(e) =>
+                        setRebootSettings({ ...rebootSettings, time: e.target.value })
+                      }
+                      className="w-full sm:w-[140px]"
+                    />
+                  </div>
+                </div>
+
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    If a pattern is drawing at the scheduled time, the reboot waits until the
+                    table is idle. The table reconnects and homes automatically after rebooting.
+                  </AlertDescription>
+                </Alert>
+              </div>
+            )}
+
+            <Button
+              onClick={handleSaveRebootSettings}
+              disabled={isLoading === 'reboot'}
+              className="gap-2"
+            >
+              {isLoading === 'reboot' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Reboot Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
         {/* Security */}
         {/* Security */}
         <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">

+ 83 - 2
main.py

@@ -345,6 +345,54 @@ async def lifespan(app: FastAPI):
 
 
     asyncio.create_task(still_sands_led_monitor())
     asyncio.create_task(still_sands_led_monitor())
 
 
+    # Start scheduled reboot monitor
+    async def scheduled_reboot_monitor():
+        """Reboot the host board daily at the configured time.
+
+        Uses the Still Sands timezone setting so all schedules share one clock.
+        Triggers at most once per day. If a pattern is drawing at the scheduled
+        time, the reboot is deferred until the table is idle (e.g. the gap
+        between playlist patterns) rather than interrupting it mid-move.
+        """
+        import shutil
+        from modules.core.pattern_manager import _get_timezone
+
+        last_trigger_date = None
+        reboot_pending = False
+        while True:
+            try:
+                await asyncio.sleep(30)
+
+                if not state.scheduled_reboot_enabled:
+                    reboot_pending = False
+                    continue
+
+                tz_info = _get_timezone()
+                now = datetime.now(tz_info) if tz_info else datetime.now()
+
+                if not reboot_pending:
+                    if now.strftime("%H:%M") != state.scheduled_reboot_time:
+                        continue
+                    if last_trigger_date == now.date():
+                        continue
+                    last_trigger_date = now.date()
+                    reboot_pending = True
+
+                if state.current_playing_file:
+                    logger.info("Scheduled reboot due, waiting for current pattern to finish")
+                    continue
+
+                reboot_pending = False
+                logger.warning("Scheduled reboot time reached, rebooting host board")
+                state.save()
+                sudo = shutil.which("sudo") or "/usr/bin/sudo"
+                subprocess.Popen([sudo, "reboot"])
+            except Exception as e:
+                logger.error(f"Error in scheduled reboot monitor: {e}")
+                await asyncio.sleep(60)
+
+    asyncio.create_task(scheduled_reboot_monitor())
+
     yield  # This separates startup from shutdown code
     yield  # This separates startup from shutdown code
 
 
     # Shutdown
     # Shutdown
@@ -418,6 +466,10 @@ class PlaylistRequest(BaseModel):
     clear_pattern: Optional[str] = None
     clear_pattern: Optional[str] = None
     run_mode: str = "single"
     run_mode: str = "single"
     shuffle: bool = False
     shuffle: bool = False
+    # When True, pause_time is measured from the START of each pattern
+    # rather than waited after it ends. Used by the "N times per day"
+    # cadence option in the UI.
+    pause_from_start: bool = False
 
 
 class PlaylistRunRequest(BaseModel):
 class PlaylistRunRequest(BaseModel):
     playlist_name: str
     playlist_name: str
@@ -488,6 +540,10 @@ class ScheduledPauseSettingsUpdate(BaseModel):
     timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York") or None for system default
     timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York") or None for system default
     time_slots: Optional[List[TimeSlot]] = None
     time_slots: Optional[List[TimeSlot]] = None
 
 
+class ScheduledRebootSettingsUpdate(BaseModel):
+    enabled: Optional[bool] = None
+    time: Optional[str] = None  # HH:MM, interpreted in the Still Sands timezone
+
 class HomingSettingsUpdate(BaseModel):
 class HomingSettingsUpdate(BaseModel):
     mode: Optional[int] = None
     mode: Optional[int] = None
     angular_offset_degrees: Optional[float] = None
     angular_offset_degrees: Optional[float] = None
@@ -544,6 +600,7 @@ class SettingsUpdate(BaseModel):
     patterns: Optional[PatternSettingsUpdate] = None
     patterns: Optional[PatternSettingsUpdate] = None
     auto_play: Optional[AutoPlaySettingsUpdate] = None
     auto_play: Optional[AutoPlaySettingsUpdate] = None
     scheduled_pause: Optional[ScheduledPauseSettingsUpdate] = None
     scheduled_pause: Optional[ScheduledPauseSettingsUpdate] = None
+    scheduled_reboot: Optional[ScheduledRebootSettingsUpdate] = None
     homing: Optional[HomingSettingsUpdate] = None
     homing: Optional[HomingSettingsUpdate] = None
     led: Optional[LedSettingsUpdate] = None
     led: Optional[LedSettingsUpdate] = None
     mqtt: Optional[MqttSettingsUpdate] = None
     mqtt: Optional[MqttSettingsUpdate] = None
@@ -761,6 +818,10 @@ async def get_all_settings():
             "timezone": state.scheduled_pause_timezone,
             "timezone": state.scheduled_pause_timezone,
             "time_slots": state.scheduled_pause_time_slots
             "time_slots": state.scheduled_pause_time_slots
         },
         },
+        "scheduled_reboot": {
+            "enabled": state.scheduled_reboot_enabled,
+            "time": state.scheduled_reboot_time
+        },
         "homing": {
         "homing": {
             "mode": state.homing,
             "mode": state.homing,
             "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
             "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
@@ -956,6 +1017,22 @@ async def update_settings(settings_update: SettingsUpdate):
             state.scheduled_pause_time_slots = [slot.model_dump() for slot in sp.time_slots]
             state.scheduled_pause_time_slots = [slot.model_dump() for slot in sp.time_slots]
         updated_categories.append("scheduled_pause")
         updated_categories.append("scheduled_pause")
 
 
+    # Scheduled reboot settings
+    if settings_update.scheduled_reboot:
+        sr = settings_update.scheduled_reboot
+        if sr.enabled is not None:
+            state.scheduled_reboot_enabled = sr.enabled
+        if sr.time is not None:
+            try:
+                datetime.strptime(sr.time, "%H:%M")
+            except ValueError:
+                raise HTTPException(
+                    status_code=400,
+                    detail="Invalid scheduled reboot time. Use HH:MM format."
+                )
+            state.scheduled_reboot_time = sr.time
+        updated_categories.append("scheduled_reboot")
+
     # Homing settings
     # Homing settings
     if settings_update.homing:
     if settings_update.homing:
         h = settings_update.homing
         h = settings_update.homing
@@ -2557,7 +2634,8 @@ async def run_playlist_endpoint(request: PlaylistRequest):
             pause_time=request.pause_time,
             pause_time=request.pause_time,
             clear_pattern=request.clear_pattern,
             clear_pattern=request.clear_pattern,
             run_mode=request.run_mode,
             run_mode=request.run_mode,
-            shuffle=request.shuffle
+            shuffle=request.shuffle,
+            pause_from_start=request.pause_from_start,
         )
         )
         if not success:
         if not success:
             raise HTTPException(status_code=409, detail=message)
             raise HTTPException(status_code=409, detail=message)
@@ -2855,7 +2933,10 @@ async def add_to_queue(request: dict):
         # Add to end
         # Add to end
         insert_index = len(playlist)
         insert_index = len(playlist)
 
 
-    playlist.insert(insert_index, pattern)
+    # Insert the joined path — current_playlist entries are THETA_RHO_DIR-joined
+    # paths (see playlist_manager.run_playlist); a bare filename would fail the
+    # existence check at execution time and silently skip the pattern
+    playlist.insert(insert_index, pattern_path)
     state.current_playlist = playlist
     state.current_playlist = playlist
 
 
     return {"success": True, "position": insert_index}
     return {"success": True, "position": insert_index}

+ 44 - 10
modules/core/pattern_manager.py

@@ -576,6 +576,21 @@ class MotionControlThread:
             'error:21',  # Invalid gcode command value
             'error:21',  # Invalid gcode command value
             'error:22',  # Invalid gcode command value in negative
             'error:22',  # Invalid gcode command value in negative
             'error:23',  # Invalid gcode command value in decimal
             'error:23',  # Invalid gcode command value in decimal
+            # FluidNC extended codes (170-181): expression/flow-control parser
+            # errors. We never send expressions or o-codes, so these can only
+            # mean a corrupted line (e.g. leading 'G' garbled into 'o')
+            'error:170', # Expression divide by zero
+            'error:171', # Expression invalid argument
+            'error:172', # Expression invalid result
+            'error:173', # Expression unknown op
+            'error:174', # Expression argument out of range
+            'error:175', # Expression syntax error
+            'error:176', # Flow control syntax error
+            'error:177', # Flow control not executing macro
+            'error:178', # Flow control out of memory
+            'error:179', # Flow control stack overflow
+            'error:180', # Parameter assignment failed
+            'error:181', # Gcode value word invalid
         }
         }
 
 
         while True:
         while True:
@@ -1515,7 +1530,7 @@ async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, c
             logger.info("Pattern execution completed, maintaining state for playlist")
             logger.info("Pattern execution completed, maintaining state for playlist")
             
             
 
 
-async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
+async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, pause_from_start=False):
     """Run multiple .thr files in sequence with options.
     """Run multiple .thr files in sequence with options.
 
 
     The playlist now stores only main patterns. Clear patterns are executed dynamically
     The playlist now stores only main patterns. Clear patterns are executed dynamically
@@ -1564,6 +1579,8 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
             # Execute main patterns using index-based access
             # Execute main patterns using index-based access
             # This allows the playlist to be reordered during execution
             # This allows the playlist to be reordered during execution
             idx = 0
             idx = 0
+            # Default in case the inner loop doesn't run before the indefinite-restart pause.
+            pattern_start_time = time.time()
             while state.current_playlist and idx < len(state.current_playlist):
             while state.current_playlist and idx < len(state.current_playlist):
                 state.current_playlist_index = idx
                 state.current_playlist_index = idx
 
 
@@ -1579,6 +1596,10 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 state.pause_time_remaining = 0
                 state.pause_time_remaining = 0
                 state.original_pause_time = None
                 state.original_pause_time = None
 
 
+                # Track pattern start time so pause_from_start mode can subtract
+                # actual run duration from the cadence interval.
+                pattern_start_time = time.time()
+
                 # Execute the pattern with optional clear pattern
                 # Execute the pattern with optional clear pattern
                 await run_theta_rho_file(
                 await run_theta_rho_file(
                     file_path,
                     file_path,
@@ -1629,19 +1650,26 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                             idle_timeout_manager.cancel_timeout()
                             idle_timeout_manager.cancel_timeout()
 
 
                 # Handle pause between patterns
                 # Handle pause between patterns
-                if state.current_playlist and idx < len(state.current_playlist) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
-                    logger.info(f"Pausing for {pause_time} seconds")
+                # In pause_from_start mode, the cadence is measured from the pattern's
+                # start time, so effective wait = pause_time - (now - pattern_start).
+                # If the pattern ran longer than the interval, we skip the pause entirely.
+                if pause_from_start:
+                    effective_pause = max(0, pause_time - (time.time() - pattern_start_time))
+                else:
+                    effective_pause = pause_time
+                if state.current_playlist and idx < len(state.current_playlist) - 1 and not state.stop_requested and effective_pause > 0 and not state.skip_requested:
+                    logger.info(f"Pausing for {effective_pause:.1f} seconds")
                     # Clear current_playing_file to report "idle" state to MQTT/HA during pause
                     # Clear current_playing_file to report "idle" state to MQTT/HA during pause
                     # This will be set again when the next pattern starts
                     # This will be set again when the next pattern starts
                     state.current_playing_file = None
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between patterns
                     # Trigger idle LED state during pause between patterns
                     await start_idle_led_timeout(check_still_sands=True)
                     await start_idle_led_timeout(check_still_sands=True)
-                    state.original_pause_time = pause_time
+                    state.original_pause_time = effective_pause
                     pause_start = time.time()
                     pause_start = time.time()
                     # Track Still Sands state for edge detection during long pauses
                     # Track Still Sands state for edge detection during long pauses
                     was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
                     was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                    while time.time() - pause_start < pause_time:
-                        state.pause_time_remaining = pause_start + pause_time - time.time()
+                    while time.time() - pause_start < effective_pause:
+                        state.pause_time_remaining = pause_start + effective_pause - time.time()
                         if state.skip_requested or state.stop_requested:
                         if state.skip_requested or state.stop_requested:
                             if state.stop_requested:
                             if state.stop_requested:
                                 logger.info("Pause interrupted by stop request")
                                 logger.info("Pause interrupted by stop request")
@@ -1687,17 +1715,23 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
 
 
             if run_mode == "indefinite":
             if run_mode == "indefinite":
                 logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
                 logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
-                if pause_time > 0:
+                # Same from-start adjustment as the inter-pattern pause: the cadence
+                # is measured from the most recent pattern's start time.
+                if pause_from_start:
+                    effective_pause = max(0, pause_time - (time.time() - pattern_start_time))
+                else:
+                    effective_pause = pause_time
+                if effective_pause > 0:
                     # Clear current_playing_file to report "idle" state to MQTT/HA during pause
                     # Clear current_playing_file to report "idle" state to MQTT/HA during pause
                     state.current_playing_file = None
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between playlist cycles
                     # Trigger idle LED state during pause between playlist cycles
                     await start_idle_led_timeout(check_still_sands=True)
                     await start_idle_led_timeout(check_still_sands=True)
-                    state.original_pause_time = pause_time
+                    state.original_pause_time = effective_pause
                     pause_start = time.time()
                     pause_start = time.time()
                     # Track Still Sands state for edge detection during long pauses
                     # Track Still Sands state for edge detection during long pauses
                     was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
                     was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                    while time.time() - pause_start < pause_time:
-                        state.pause_time_remaining = pause_start + pause_time - time.time()
+                    while time.time() - pause_start < effective_pause:
+                        state.pause_time_remaining = pause_start + effective_pause - time.time()
                         if state.skip_requested or state.stop_requested:
                         if state.skip_requested or state.stop_requested:
                             if state.stop_requested:
                             if state.stop_requested:
                                 logger.info("Pause interrupted by stop request")
                                 logger.info("Pause interrupted by stop request")

+ 2 - 1
modules/core/playlist_manager.py

@@ -138,7 +138,7 @@ async def cancel_current_playlist():
             logger.warning(f"Error while cancelling playlist task: {e}")
             logger.warning(f"Error while cancelling playlist task: {e}")
         _current_playlist_task = None
         _current_playlist_task = None
 
 
-async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
+async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, pause_from_start=False):
     """Run a playlist with the given options."""
     """Run a playlist with the given options."""
     global _current_playlist_task
     global _current_playlist_task
 
 
@@ -178,6 +178,7 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
                 clear_pattern=clear_pattern,
                 clear_pattern=clear_pattern,
                 run_mode=run_mode,
                 run_mode=run_mode,
                 shuffle=shuffle,
                 shuffle=shuffle,
+                pause_from_start=pause_from_start,
             )
             )
         )
         )
         return True, f"Playlist '{playlist_name}' is now running."
         return True, f"Playlist '{playlist_name}' is now running."

+ 8 - 0
modules/core/state.py

@@ -163,6 +163,10 @@ class AppState:
         self.scheduled_pause_finish_pattern = False  # Finish current pattern before pausing
         self.scheduled_pause_finish_pattern = False  # Finish current pattern before pausing
         self.scheduled_pause_timezone = None  # User-selected timezone (None = use system timezone)
         self.scheduled_pause_timezone = None  # User-selected timezone (None = use system timezone)
 
 
+        # Scheduled reboot settings (reboots the host board, e.g. Raspberry Pi)
+        self.scheduled_reboot_enabled = False
+        self.scheduled_reboot_time = "03:00"  # HH:MM, uses Still Sands timezone setting
+
         # Server port setting (requires restart to take effect)
         # Server port setting (requires restart to take effect)
         self.server_port = 8080  # Default server port
         self.server_port = 8080  # Default server port
 
 
@@ -536,6 +540,8 @@ class AppState:
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
             "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
             "scheduled_pause_timezone": self.scheduled_pause_timezone,
             "scheduled_pause_timezone": self.scheduled_pause_timezone,
+            "scheduled_reboot_enabled": self.scheduled_reboot_enabled,
+            "scheduled_reboot_time": self.scheduled_reboot_time,
             "server_port": self.server_port,
             "server_port": self.server_port,
             "timezone": self.timezone,
             "timezone": self.timezone,
             "mqtt_enabled": self.mqtt_enabled,
             "mqtt_enabled": self.mqtt_enabled,
@@ -657,6 +663,8 @@ class AppState:
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
         self.scheduled_pause_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
         self.scheduled_pause_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
         self.scheduled_pause_timezone = data.get("scheduled_pause_timezone", None)
         self.scheduled_pause_timezone = data.get("scheduled_pause_timezone", None)
+        self.scheduled_reboot_enabled = data.get("scheduled_reboot_enabled", False)
+        self.scheduled_reboot_time = data.get("scheduled_reboot_time", "03:00")
         self.server_port = data.get("server_port", 8080)
         self.server_port = data.get("server_port", 8080)
         self.timezone = data.get("timezone", "UTC")
         self.timezone = data.get("timezone", "UTC")
         self.mqtt_enabled = data.get("mqtt_enabled", False)
         self.mqtt_enabled = data.get("mqtt_enabled", False)

+ 35 - 14
modules/mqtt/handler.py

@@ -399,18 +399,20 @@ class MQTTHandler(BaseMQTTHandler):
             }
             }
             self._publish_discovery("number", "led_intensity", led_intensity_config)
             self._publish_discovery("number", "led_intensity", led_intensity_config)
 
 
-            # LED RGB Color Control
+            # LED RGB Color Control (HA MQTT Light, JSON schema).
+            # Color support must be declared via supported_color_modes;
+            # the legacy "rgb" flag and the basic-schema rgb_*_topic keys
+            # are ignored by the JSON schema and HA would register a plain
+            # on/off light without a color picker.
             led_color_config = {
             led_color_config = {
                 "name": f"{self.device_name} LED Color",
                 "name": f"{self.device_name} LED Color",
                 "unique_id": f"{self.device_id}_led_color",
                 "unique_id": f"{self.device_id}_led_color",
                 "command_topic": self.led_color_topic,
                 "command_topic": self.led_color_topic,
                 "state_topic": f"{self.device_id}/led/color/state",
                 "state_topic": f"{self.device_id}/led/color/state",
-                "rgb_command_topic": self.led_color_topic,
-                "rgb_state_topic": f"{self.device_id}/led/color/state",
                 "device": base_device,
                 "device": base_device,
                 "icon": "mdi:palette-swatch",
                 "icon": "mdi:palette-swatch",
                 "schema": "json",
                 "schema": "json",
-                "rgb": True
+                "supported_color_modes": ["rgb"]
             }
             }
             self._publish_discovery("light", "led_color", led_color_config)
             self._publish_discovery("light", "led_color", led_color_config)
 
 
@@ -582,7 +584,7 @@ class MQTTHandler(BaseMQTTHandler):
             if "intensity" in status:
             if "intensity" in status:
                 self.client.publish(f"{self.device_id}/led/intensity/state", status["intensity"], retain=True)
                 self.client.publish(f"{self.device_id}/led/intensity/state", status["intensity"], retain=True)
 
 
-            # Publish color (RGB)
+            # Publish color light state (HA JSON light schema)
             if "colors" in status and len(status["colors"]) > 0:
             if "colors" in status and len(status["colors"]) > 0:
                 # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
                 # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
                 # Convert first color to RGB dict
                 # Convert first color to RGB dict
@@ -591,8 +593,13 @@ class MQTTHandler(BaseMQTTHandler):
                     r = int(color_hex[1:3], 16)
                     r = int(color_hex[1:3], 16)
                     g = int(color_hex[3:5], 16)
                     g = int(color_hex[3:5], 16)
                     b = int(color_hex[5:7], 16)
                     b = int(color_hex[5:7], 16)
+                    light_state = {
+                        "state": "ON" if is_powered else "OFF",
+                        "color_mode": "rgb",
+                        "color": {"r": r, "g": g, "b": b}
+                    }
                     self.client.publish(f"{self.device_id}/led/color/state",
                     self.client.publish(f"{self.device_id}/led/color/state",
-                                      json.dumps({"r": r, "g": g, "b": b}), retain=True)
+                                      json.dumps(light_state), retain=True)
 
 
         except Exception as e:
         except Exception as e:
             logger.error(f"Error publishing LED state: {e}")
             logger.error(f"Error publishing LED state: {e}")
@@ -843,16 +850,30 @@ class MQTTHandler(BaseMQTTHandler):
                         controller.set_intensity(intensity)
                         controller.set_intensity(intensity)
                         self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
                         self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
             elif msg.topic == self.led_color_topic:
             elif msg.topic == self.led_color_topic:
-                # Handle LED color command (RGB) (DW LEDs only)
+                # Handle LED color light command (HA JSON light schema) (DW LEDs only)
+                # HA sends e.g. {"state": "ON", "color": {"r": 255, "g": 0, "b": 0}}
                 try:
                 try:
                     color_data = json.loads(msg.payload.decode())
                     color_data = json.loads(msg.payload.decode())
-                    if state.led_controller and state.led_provider == "dw_leds" and 'r' in color_data and 'g' in color_data and 'b' in color_data:
-                        controller = state.led_controller.get_controller()
-                        if controller and hasattr(controller, 'set_color'):
-                            r, g, b = color_data['r'], color_data['g'], color_data['b']
-                            controller.set_color(r, g, b)
-                            self.client.publish(f"{self.device_id}/led/color/state",
-                                              json.dumps({"r": r, "g": g, "b": b}), retain=True)
+                    if state.led_controller and state.led_provider == "dw_leds":
+                        light_on = color_data.get('state', 'ON') == 'ON'
+                        state.led_controller.set_power(1 if light_on else 0)
+                        if light_on and state.dw_led_idle_timeout_enabled:
+                            state.dw_led_last_activity_time = time.time()
+                        # Accept nested JSON-schema color; fall back to legacy
+                        # top-level {"r","g","b"} for existing automations
+                        color = color_data.get('color')
+                        if color is None and all(k in color_data for k in ('r', 'g', 'b')):
+                            color = color_data
+                        light_state = {"state": "ON" if light_on else "OFF"}
+                        if light_on and color and all(k in color for k in ('r', 'g', 'b')):
+                            controller = state.led_controller.get_controller()
+                            if controller and hasattr(controller, 'set_color'):
+                                r, g, b = color['r'], color['g'], color['b']
+                                controller.set_color(r, g, b)
+                                light_state["color_mode"] = "rgb"
+                                light_state["color"] = {"r": r, "g": g, "b": b}
+                        self.client.publish(f"{self.device_id}/led/color/state",
+                                          json.dumps(light_state), retain=True)
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     logger.error(f"Invalid JSON for color command: {msg.payload}")
                     logger.error(f"Invalid JSON for color command: {msg.payload}")
             elif msg.topic == self.screen_power_topic:
             elif msg.topic == self.screen_power_topic:

+ 1 - 0
requirements-nonrpi.txt

@@ -20,6 +20,7 @@ aiofiles>=23.1.0
 python-multipart>=0.0.6
 python-multipart>=0.0.6
 websockets>=11.0.3  # Required for FastAPI WebSocket support
 websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
 requests>=2.31.0
+pyyaml>=6.0  # Required for FluidNC config parsing
 Pillow
 Pillow
 aiohttp
 aiohttp
 zeroconf>=0.131.0  
 zeroconf>=0.131.0  

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/dist/assets/index-BdHxEIen.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/dist/assets/index-Bg3BUsME.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/dist/assets/index-MuBercVK.js


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
           .catch(function() {});
       })();
       })();
     </script>
     </script>
-    <script type="module" crossorigin src="/assets/index-BdHxEIen.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CHzltdTQ.css">
+    <script type="module" crossorigin src="/assets/index-MuBercVK.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bg3BUsME.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/dist/sw.js


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio