Browse Source

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 1 tháng trước cách đây
mục cha
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')
     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')
-    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 cached = localStorage.getItem('preExecution')
@@ -168,9 +168,13 @@ export function PlaylistsPage() {
   }, [])
   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 = () => {
     switch (pauseUnit) {
+      case 'per_day':
+        return Math.floor(86400 / Math.max(1, pauseTime))
       case 'hr':
         return pauseTime * 3600
       case 'min':
@@ -462,6 +466,7 @@ export function PlaylistsPage() {
         pause_time: getPauseTimeInSeconds(),
         clear_pattern: clearPattern,
         shuffle: shuffle,
+        pause_from_start: pauseUnit === 'per_day',
       })
       toast.success(`Started playlist: ${selectedPlaylist}`)
       // Trigger Now Playing bar to open
@@ -800,21 +805,30 @@ export function PlaylistsPage() {
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         onClick={() => {
                           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>
                       </Button>
                       <button
                         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)
-                          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"
-                        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>
                       </button>
                       <Button
@@ -823,7 +837,10 @@ export function PlaylistsPage() {
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         onClick={() => {
                           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>

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

@@ -171,6 +171,12 @@ export function SettingsPage() {
     time_slots: [],
   })
 
+  // Scheduled reboot state
+  const [rebootSettings, setRebootSettings] = useState<{ enabled: boolean; time: string }>({
+    enabled: false,
+    time: '03:00',
+  })
+
   // Pattern search state for clearing patterns
   const [patternFiles, setPatternFiles] = useState<string[]>([])
 
@@ -397,6 +403,13 @@ export function SettingsPage() {
           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
       if (data.security) {
         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 = () => {
     setStillSandsSettings({
       ...stillSandsSettings,
@@ -2275,6 +2302,88 @@ export function SettingsPage() {
           </AccordionContent>
         </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 */}
         <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
           <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())
 
+    # 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
 
     # Shutdown
@@ -418,6 +466,10 @@ class PlaylistRequest(BaseModel):
     clear_pattern: Optional[str] = None
     run_mode: str = "single"
     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):
     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
     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):
     mode: Optional[int] = None
     angular_offset_degrees: Optional[float] = None
@@ -544,6 +600,7 @@ class SettingsUpdate(BaseModel):
     patterns: Optional[PatternSettingsUpdate] = None
     auto_play: Optional[AutoPlaySettingsUpdate] = None
     scheduled_pause: Optional[ScheduledPauseSettingsUpdate] = None
+    scheduled_reboot: Optional[ScheduledRebootSettingsUpdate] = None
     homing: Optional[HomingSettingsUpdate] = None
     led: Optional[LedSettingsUpdate] = None
     mqtt: Optional[MqttSettingsUpdate] = None
@@ -761,6 +818,10 @@ async def get_all_settings():
             "timezone": state.scheduled_pause_timezone,
             "time_slots": state.scheduled_pause_time_slots
         },
+        "scheduled_reboot": {
+            "enabled": state.scheduled_reboot_enabled,
+            "time": state.scheduled_reboot_time
+        },
         "homing": {
             "mode": state.homing,
             "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]
         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
     if settings_update.homing:
         h = settings_update.homing
@@ -2557,7 +2634,8 @@ async def run_playlist_endpoint(request: PlaylistRequest):
             pause_time=request.pause_time,
             clear_pattern=request.clear_pattern,
             run_mode=request.run_mode,
-            shuffle=request.shuffle
+            shuffle=request.shuffle,
+            pause_from_start=request.pause_from_start,
         )
         if not success:
             raise HTTPException(status_code=409, detail=message)
@@ -2855,7 +2933,10 @@ async def add_to_queue(request: dict):
         # Add to end
         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
 
     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:22',  # Invalid gcode command value in negative
             '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:
@@ -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")
             
 
-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.
 
     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
             # This allows the playlist to be reordered during execution
             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):
                 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.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
                 await run_theta_rho_file(
                     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()
 
                 # 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
                     # This will be set again when the next pattern starts
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between patterns
                     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()
                     # 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
-                    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.stop_requested:
                                 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":
                 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
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between playlist cycles
                     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()
                     # 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
-                    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.stop_requested:
                                 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}")
         _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."""
     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,
                 run_mode=run_mode,
                 shuffle=shuffle,
+                pause_from_start=pause_from_start,
             )
         )
         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_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)
         self.server_port = 8080  # Default server port
 
@@ -536,6 +540,8 @@ class AppState:
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
             "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,
             "timezone": self.timezone,
             "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_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
         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.timezone = data.get("timezone", "UTC")
         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)
 
-            # 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 = {
                 "name": f"{self.device_name} LED Color",
                 "unique_id": f"{self.device_id}_led_color",
                 "command_topic": self.led_color_topic,
                 "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,
                 "icon": "mdi:palette-swatch",
                 "schema": "json",
-                "rgb": True
+                "supported_color_modes": ["rgb"]
             }
             self._publish_discovery("light", "led_color", led_color_config)
 
@@ -582,7 +584,7 @@ class MQTTHandler(BaseMQTTHandler):
             if "intensity" in status:
                 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:
                 # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
                 # Convert first color to RGB dict
@@ -591,8 +593,13 @@ class MQTTHandler(BaseMQTTHandler):
                     r = int(color_hex[1:3], 16)
                     g = int(color_hex[3:5], 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",
-                                      json.dumps({"r": r, "g": g, "b": b}), retain=True)
+                                      json.dumps(light_state), retain=True)
 
         except Exception as e:
             logger.error(f"Error publishing LED state: {e}")
@@ -843,16 +850,30 @@ class MQTTHandler(BaseMQTTHandler):
                         controller.set_intensity(intensity)
                         self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
             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:
                     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:
                     logger.error(f"Invalid JSON for color command: {msg.payload}")
             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
 websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
+pyyaml>=6.0  # Required for FluidNC config parsing
 Pillow
 aiohttp
 zeroconf>=0.131.0  

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/dist/assets/index-BdHxEIen.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/dist/assets/index-Bg3BUsME.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/dist/assets/index-MuBercVK.js


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
       })();
     </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>
   <body>
     <div id="root"></div>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/dist/sw.js


Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác