5 Комити a100c6c0a2 ... 66f59b8630

Аутор SHA1 Порука Датум
  tuanchris 66f59b8630 chore: sync frontend lockfile with GPL-3.0 license field пре 4 дана
  m8tec 099ed30b9c fix(mqtt): allow custom client ID override, stop writing literal default (#125) пре 4 дана
  m8tec 73139b99d9 fix(mqtt): apply clear pattern when selecting a single pattern (#123) пре 4 дана
  Andrey ea8477a2a3 fix(backend): re-shuffle playlists on each repeat cycle (#139) пре 4 дана
  Andrey 51eee178a2 fix(backend): repeat playlists when run_mode is loop (#138) пре 4 дана

+ 1 - 0
frontend/package-lock.json

@@ -7,6 +7,7 @@
     "": {
       "name": "frontend",
       "version": "0.0.0",
+      "license": "GPL-3.0-or-later",
       "dependencies": {
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/sortable": "^10.0.0",

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

@@ -1749,6 +1749,22 @@ export function SettingsPage() {
                       }
                     />
                   </div>
+                  <div className="space-y-3 md:col-span-2">
+                    <Label htmlFor="mqttClientId">Client ID (optional)</Label>
+                    <Input
+                      id="mqttClientId"
+                      value={mqttConfig.client_id || ''}
+                      onChange={(e) =>
+                        setMqttConfig({ ...mqttConfig, client_id: e.target.value })
+                      }
+                      placeholder="Auto (unique ID based on Device ID)"
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      {mqttConfig.client_id?.trim()
+                        ? 'Using custom Client ID override. Must be unique per table on the broker.'
+                        : 'Leave empty to auto-generate a unique broker client ID from the Device ID.'}
+                    </p>
+                  </div>
                 </div>
 
                 <Alert className="flex items-start">

+ 1 - 1
main.py

@@ -3291,7 +3291,7 @@ async def set_mqtt_config(request: dict):
         state.mqtt_port = int(request.get("port") or 1883)
         state.mqtt_username = (request.get("username") or "").strip()
         state.mqtt_password = (request.get("password") or "").strip()
-        state.mqtt_client_id = (request.get("client_id") or "dune_weaver").strip()
+        state.mqtt_client_id = (request.get("client_id") or "").strip() or None
         state.mqtt_discovery_prefix = (request.get("discovery_prefix") or "homeassistant").strip()
         state.mqtt_device_id = (request.get("device_id") or "dune_weaver").strip()
         state.mqtt_device_name = (request.get("device_name") or "Dune Weaver").strip()

+ 9 - 7
modules/core/pattern_manager.py

@@ -1539,11 +1539,6 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
     if not progress_update_task:
         progress_update_task = asyncio.create_task(broadcast_progress())
 
-    # Shuffle main patterns if requested (before starting)
-    if shuffle:
-        random.shuffle(file_paths)
-        logger.info("Playlist shuffled")
-
     # Store patterns in state only if not already set by caller.
     # The caller (playlist_manager.run_playlist) sets this before creating the task.
     if state.current_playlist is None:
@@ -1551,6 +1546,13 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
 
     try:
         while True:
+            # Shuffle main patterns if requested. Re-shuffle at the start of
+            # every cycle so repeat modes play a fresh order each pass instead
+            # of replaying the first random order forever.
+            if shuffle and state.current_playlist:
+                random.shuffle(state.current_playlist)
+                logger.info("Playlist shuffled")
+
             # Load metadata cache once per playlist iteration (for adaptive clear patterns)
             cache_data = None
             if clear_pattern and clear_pattern in ['adaptive', 'clear_from_in', 'clear_from_out']:
@@ -1685,8 +1687,8 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 state.skip_requested = False
                 idx += 1
 
-            if run_mode == "indefinite":
-                logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
+            if run_mode in ("indefinite", "loop"):
+                logger.info(f"Playlist completed. Restarting as per '{run_mode}' run mode")
                 if pause_time > 0:
                     # Clear current_playing_file to report "idle" state to MQTT/HA during pause
                     state.current_playing_file = None

+ 4 - 1
modules/mqtt/handler.py

@@ -699,7 +699,10 @@ class MQTTHandler(BaseMQTTHandler):
                 if pattern_name in self.patterns:
                     # Schedule the coroutine to run in the main event loop
                     asyncio.run_coroutine_threadsafe(
-                        self.callback_registry['run_pattern'](file_path=f"{THETA_RHO_DIR}/{pattern_name}"),
+                        self.callback_registry['run_pattern'](
+                            file_path=f"{THETA_RHO_DIR}/{pattern_name}",
+                            clear_pattern=self.state.clear_pattern,
+                        ),
                         self.main_loop
                     ).add_done_callback(
                         lambda _: self._publish_pattern_state(None)  # Clear pattern after execution

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/dist/assets/index-BdTg2vct.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/dist/assets/index-D3rZVjEB.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-D3rZVjEB.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BdTg2vct.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
     <div id="root"></div>

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/dist/sw.js


+ 109 - 2
tests/unit/test_pattern_manager.py

@@ -7,9 +7,8 @@ Tests the core pattern file operations:
 - Error handling for invalid files
 - Listing pattern files
 """
-import os
 import pytest
-from unittest.mock import patch, MagicMock
+from unittest.mock import AsyncMock, patch
 
 
 class TestParseTheTaRhoFile:
@@ -316,6 +315,61 @@ class TestGetStatus:
         assert status["playlist"]["name"] == "test_playlist"
 
 
+class TestPlaylistShuffle:
+    """Tests for playlist shuffle behavior in run_theta_rho_files."""
+
+    async def test_shuffle_reshuffles_on_each_repeat_cycle(self, mock_state):
+        """With shuffle enabled, a repeating playlist must re-shuffle every
+        cycle instead of replaying the first random order forever."""
+        playlist = ["a.thr", "b.thr"]
+        executed = []
+        shuffle_calls = []
+
+        async def fake_run_pattern(file_path, **kwargs):
+            executed.append(file_path)
+            if len(executed) >= 3:
+                mock_state.stop_requested = True
+
+        with patch("modules.core.pattern_manager.state", mock_state), \
+             patch("modules.core.pattern_manager.run_theta_rho_file",
+                   AsyncMock(side_effect=fake_run_pattern)), \
+             patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
+             patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()), \
+             patch("modules.core.pattern_manager.random.shuffle",
+                   side_effect=lambda seq: shuffle_calls.append(list(seq))):
+            from modules.core.pattern_manager import run_theta_rho_files
+
+            await run_theta_rho_files(playlist, run_mode="indefinite", shuffle=True)
+
+        # The playlist restarted once (3 patterns executed from a 2-pattern
+        # playlist), so it must have been shuffled twice: once per cycle.
+        assert len(executed) == 3
+        assert len(shuffle_calls) == 2
+
+    async def test_no_shuffle_preserves_order(self, mock_state):
+        """Without shuffle, the playlist order is never touched."""
+        playlist = ["a.thr", "b.thr"]
+        executed = []
+        shuffle_calls = []
+
+        async def fake_run_pattern(file_path, **kwargs):
+            executed.append(file_path)
+
+        with patch("modules.core.pattern_manager.state", mock_state), \
+             patch("modules.core.pattern_manager.run_theta_rho_file",
+                   AsyncMock(side_effect=fake_run_pattern)), \
+             patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
+             patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()), \
+             patch("modules.core.pattern_manager.random.shuffle",
+                   side_effect=lambda seq: shuffle_calls.append(list(seq))):
+            from modules.core.pattern_manager import run_theta_rho_files
+
+            await run_theta_rho_files(playlist, run_mode="single", shuffle=False)
+
+        assert executed == ["a.thr", "b.thr"]
+        assert shuffle_calls == []
+
+
 class TestIsClearPattern:
     """Tests for is_clear_pattern function."""
 
@@ -350,3 +404,56 @@ class TestIsClearPattern:
         assert is_clear_pattern("./patterns/circle.thr") is False
         assert is_clear_pattern("./patterns/spiral.thr") is False
         assert is_clear_pattern("./patterns/custom/my_pattern.thr") is False
+
+
+class TestPlaylistRunModes:
+    """Tests for run_theta_rho_files repeat behavior.
+
+    The manual "Run Playlist" dialog sends run_mode="indefinite", while
+    auto-play and MQTT send run_mode="loop". Both must repeat the playlist;
+    "single" must stop after one pass.
+    """
+
+    PLAYLIST = ["a.thr", "b.thr"]
+
+    async def _run_playlist(self, mock_state, run_mode, stop_after):
+        """Run a playlist with a stubbed pattern executor.
+
+        Requests a stop once the executor has run stop_after patterns (so
+        repeat modes can't spin forever), then returns the executed files.
+        """
+        executed = []
+
+        async def fake_run_pattern(file_path, **kwargs):
+            executed.append(file_path)
+            if len(executed) >= stop_after:
+                mock_state.stop_requested = True
+
+        with patch("modules.core.pattern_manager.state", mock_state), \
+             patch("modules.core.pattern_manager.run_theta_rho_file",
+                   AsyncMock(side_effect=fake_run_pattern)), \
+             patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
+             patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()):
+            from modules.core.pattern_manager import run_theta_rho_files
+
+            await run_theta_rho_files(list(self.PLAYLIST), run_mode=run_mode)
+
+        return executed
+
+    async def test_run_mode_single_stops_after_one_pass(self, mock_state):
+        """A "single" playlist runs each pattern once and completes."""
+        executed = await self._run_playlist(mock_state, "single", stop_after=10)
+
+        assert executed == ["a.thr", "b.thr"]
+
+    async def test_run_mode_indefinite_restarts_playlist(self, mock_state):
+        """An "indefinite" playlist restarts after the last pattern."""
+        executed = await self._run_playlist(mock_state, "indefinite", stop_after=3)
+
+        assert executed == ["a.thr", "b.thr", "a.thr"]
+
+    async def test_run_mode_loop_restarts_playlist(self, mock_state):
+        """A "loop" playlist (auto-play/MQTT) restarts after the last pattern."""
+        executed = await self._run_playlist(mock_state, "loop", stop_after=3)
+
+        assert executed == ["a.thr", "b.thr", "a.thr"]

Неке датотеке нису приказане због велике количине промена