فهرست منبع

Merge origin/main into feature/react-ui

Resolved 3 conflicts integrating bug fixes from main:
- connection_manager.py: Added self.ws = None after close (NoneType fix)
- state.py: Added shuffle property alongside event-based execution control
- pattern_manager.py: Kept feature branch's comprehensive retry/recovery logic

Also fixed pre-existing ruff lint issues in staged files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 روز پیش
والد
کامیت
d2418bea9e
4فایلهای تغییر یافته به همراه165 افزوده شده و 26 حذف شده
  1. 100 0
      firmware/dune_weaver_gold/config (DAGGKAPRIFOL).yaml
  2. 17 18
      modules/connection/connection_manager.py
  3. 13 2
      modules/core/state.py
  4. 35 6
      modules/mqtt/handler.py

+ 100 - 0
firmware/dune_weaver_gold/config (DAGGKAPRIFOL).yaml

@@ -0,0 +1,100 @@
+board: MKS-DLC32 V2.1
+name: Dune Weaver Gold
+meta: By Tuan Nguyen (2025-12-25)
+kinematics: {}
+stepping:
+  engine: I2S_STATIC
+  idle_ms: 0
+  pulse_us: 4
+  dir_delay_us: 1
+  disable_delay_us: 0
+axes:
+  shared_stepper_disable_pin: i2so.0
+  x:
+    steps_per_mm: 200
+    max_rate_mm_per_min: 500
+    acceleration_mm_per_sec2: 10
+    max_travel_mm: 325
+    soft_limits: false
+    motor0:
+      limit_neg_pin: gpio.36
+      hard_limits: false
+      pulloff_mm: 2
+      stepstick:
+        step_pin: i2so.1
+        direction_pin: i2so.2
+        disable_pin: NO_PIN
+        ms1_pin: NO_PIN
+        ms2_pin: NO_PIN
+        ms3_pin: NO_PIN
+      limit_pos_pin: NO_PIN
+      limit_all_pin: NO_PIN
+  y:
+    steps_per_mm: 250
+    max_rate_mm_per_min: 500
+    acceleration_mm_per_sec2: 10
+    max_travel_mm: 6.25
+    soft_limits: false
+    motor0:
+      limit_neg_pin: gpio.35
+      hard_limits: false
+      pulloff_mm: 2
+      stepstick:
+        step_pin: i2so.5
+        direction_pin: i2so.6:low
+        disable_pin: NO_PIN
+        ms1_pin: NO_PIN
+        ms2_pin: NO_PIN
+        ms3_pin: NO_PIN
+      limit_pos_pin: NO_PIN
+      limit_all_pin: NO_PIN
+i2so:
+  bck_pin: gpio.16
+  data_pin: gpio.21
+  ws_pin: gpio.17
+sdcard:
+  cs_pin: gpio.15
+  card_detect_pin: NO_PIN
+control:
+  safety_door_pin: NO_PIN
+  reset_pin: NO_PIN
+  feed_hold_pin: NO_PIN
+  cycle_start_pin: NO_PIN
+  macro0_pin: gpio.33:pu:low
+  macro1_pin: NO_PIN
+  macro2_pin: NO_PIN
+  macro3_pin: NO_PIN
+  fault_pin: NO_PIN
+  estop_pin: NO_PIN
+macros:
+  macro0: G90
+coolant:
+  flood_pin: NO_PIN
+  mist_pin: NO_PIN
+  delay_ms: 0
+user_outputs:
+  analog0_pin: NO_PIN
+  analog1_pin: NO_PIN
+  analog2_pin: NO_PIN
+  analog3_pin: NO_PIN
+  analog0_hz: 5000
+  analog1_hz: 5000
+  analog2_hz: 5000
+  analog3_hz: 5000
+  digital0_pin: NO_PIN
+  digital1_pin: NO_PIN
+  digital2_pin: NO_PIN
+  digital3_pin: NO_PIN
+start:
+  must_home: false
+spi:
+  miso_pin: gpio.12
+  mosi_pin: gpio.13
+  sck_pin: gpio.14
+uart1:
+  txd_pin: gpio.19
+  rxd_pin: gpio.18
+  baud: 115200
+  mode: 8N1
+uart_channel1:
+  uart_num: 1

+ 17 - 18
modules/connection/connection_manager.py

@@ -7,14 +7,13 @@ import websocket
 import asyncio
 import os
 
-from modules.core import pattern_manager
 from modules.core.state import state
 from modules.led.led_interface import LEDInterface
 from modules.led.idle_timeout_manager import idle_timeout_manager
 
 logger = logging.getLogger(__name__)
 
-IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
+IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port', '/dev/ttyS0']
 
 # Ports to deprioritize during auto-connect (shown in UI but not auto-selected)
 DEPRIORITIZED_PORTS = ['/dev/ttyS0']
@@ -113,7 +112,7 @@ class SerialConnection(BaseConnection):
         # Schedule async position update if event loop exists, otherwise skip
         # This avoids creating nested event loops which causes RuntimeError
         try:
-            loop = asyncio.get_running_loop()
+            asyncio.get_running_loop()
             # We're in async context - schedule as task (fire-and-forget)
             asyncio.create_task(update_machine_position())
             logger.debug("Scheduled async machine position update")
@@ -176,7 +175,7 @@ class WebSocketConnection(BaseConnection):
         # Schedule async position update if event loop exists, otherwise skip
         # This avoids creating nested event loops which causes RuntimeError
         try:
-            loop = asyncio.get_running_loop()
+            asyncio.get_running_loop()
             # We're in async context - schedule as task (fire-and-forget)
             asyncio.create_task(update_machine_position())
             logger.debug("Scheduled async machine position update")
@@ -188,6 +187,7 @@ class WebSocketConnection(BaseConnection):
         with self.lock:
             if self.ws:
                 self.ws.close()
+                self.ws = None
 
 def list_serial_ports():
     """Return a list of available serial ports."""
@@ -208,7 +208,7 @@ def device_init(homing=True):
             logger.fatal("Failed to get machine steps")
             state.conn.close()
             return False
-    except:
+    except Exception:
         logger.fatal("Not GRBL firmware")
         state.conn.close()
         return False
@@ -506,7 +506,7 @@ async def send_grbl_coordinates(x, y, speed=600, timeout=30, home=False):
             while time.time() - response_start < response_timeout:
                 # Check overall timeout
                 if time.time() - overall_start_time > timeout:
-                    logger.error(f"Overall timeout waiting for 'ok' response")
+                    logger.error("Overall timeout waiting for 'ok' response")
                     return False
 
                 response = await asyncio.to_thread(state.conn.readline)
@@ -717,7 +717,6 @@ def _get_steps_fluidnc():
                         try:
                             homing_cycle = int(float(response.split('=')[1].strip()))
                             # cycle >= 1 means homing is enabled in firmware
-                            firmware_homing = 1 if homing_cycle >= 1 else 0
                             logger.info(f"Firmware homing setting (cycle): {homing_cycle}, using user preference: {state.homing}")
                         except (ValueError, IndexError):
                             pass
@@ -853,14 +852,12 @@ def get_machine_steps(timeout=10):
         time.sleep(0.2)
         ready_check_attempts = 5
         controller_ready = False
-        in_alarm = False
         for _ in range(ready_check_attempts):
             if state.conn.in_waiting() > 0:
                 response = state.conn.readline()
                 if response and ('<' in response or 'Idle' in response or 'Alarm' in response):
                     controller_ready = True
                     if 'Alarm' in response:
-                        in_alarm = True
                         logger.info(f"Controller in ALARM state (likely limit switch active), proceeding with settings query: {response.strip()}")
                     else:
                         logger.debug(f"Controller ready, status: {response}")
@@ -908,7 +905,7 @@ def get_machine_steps(timeout=10):
             state.table_type = 'dune_weaver_mini'
         elif y_steps_per_mm == 210 and x_steps_per_mm == 256:
             state.table_type = 'dune_weaver_mini_pro_byj'
-        elif y_steps_per_mm == 270 and x_steps_per_mm == 200:
+        elif (y_steps_per_mm == 270 or y_steps_per_mm == 250) and x_steps_per_mm == 200:
             state.table_type = 'dune_weaver_gold'
         elif y_steps_per_mm == 287:
             state.table_type = 'dune_weaver'
@@ -946,8 +943,10 @@ def get_machine_steps(timeout=10):
         return True
     else:
         missing = []
-        if x_steps_per_mm is None: missing.append("X steps/mm")
-        if y_steps_per_mm is None: missing.append("Y steps/mm")
+        if x_steps_per_mm is None:
+            missing.append("X steps/mm")
+        if y_steps_per_mm is None:
+            missing.append("Y steps/mm")
         logger.error(f"Failed to get all machine parameters after {timeout}s. Missing: {', '.join(missing)}")
         return False
 
@@ -1062,13 +1061,13 @@ def home(timeout=120):
                     try:
                         if effective_table_type == 'dune_weaver_mini':
                             result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
-                            if result == False:
+                            if not result:
                                 logger.error("Crash homing fallback failed")
                                 homing_complete.set()
                                 return
                         else:
                             result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
-                            if result == False:
+                            if not result:
                                 logger.error("Crash homing fallback failed")
                                 homing_complete.set()
                                 return
@@ -1105,7 +1104,7 @@ def home(timeout=120):
                     try:
                         # Send G1 X0 Y0 F{homing_speed}
                         result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
-                        if result == False:
+                        if not result:
                             logger.error("Position zeroing failed - send_grbl_coordinates returned False")
                             homing_complete.set()
                             return
@@ -1138,14 +1137,14 @@ def home(timeout=120):
                 try:
                     if effective_table_type == 'dune_weaver_mini':
                         result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
-                        if result == False:
+                        if not result:
                             logger.error("Crash homing failed - send_grbl_coordinates returned False")
                             homing_complete.set()
                             return
                         state.machine_y -= 30
                     else:
                         result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
-                        if result == False:
+                        if not result:
                             logger.error("Crash homing failed - send_grbl_coordinates returned False")
                             homing_complete.set()
                             return
@@ -1228,7 +1227,7 @@ def check_idle():
             try:
                 # Try to schedule in existing event loop if available
                 try:
-                    loop = asyncio.get_running_loop()
+                    asyncio.get_running_loop()
                     # Create a task but don't await it (fire and forget)
                     asyncio.create_task(update_machine_position())
                     logger.debug("Scheduled machine position update task")

+ 13 - 2
modules/core/state.py

@@ -116,6 +116,7 @@ class AppState:
         self._pause_time = 0
         self._clear_pattern = "none"
         self._clear_pattern_speed = None  # None means use state.speed as default
+        self._shuffle = False  # Shuffle playlist order
         self.custom_clear_from_in = None  # Custom clear from center pattern
         self.custom_clear_from_out = None  # Custom clear from perimeter pattern
         
@@ -184,7 +185,7 @@ class AppState:
         self._current_playing_file = value
 
         # force an empty string (and not None) if we need to unset
-        if value == None:
+        if value is None:
             value = ""
         if self.mqtt_handler:
             is_running = bool(value and not self._pause_requested)
@@ -220,7 +221,7 @@ class AppState:
         self._current_playlist = value
         
         # force an empty string (and not None) if we need to unset
-        if value == None:
+        if value is None:
             value = ""
             # Also clear the playlist name when playlist is cleared
             self._current_playlist_name = None
@@ -431,6 +432,14 @@ class AppState:
             return 'skipped'
         return 'timeout'
 
+    @property
+    def shuffle(self):
+        return self._shuffle
+
+    @shuffle.setter
+    def shuffle(self, value):
+        self._shuffle = value
+
     def to_dict(self):
         """Return a dictionary representation of the state."""
         return {
@@ -460,6 +469,7 @@ class AppState:
             "pause_time": self._pause_time,
             "clear_pattern": self._clear_pattern,
             "clear_pattern_speed": self._clear_pattern_speed,
+            "shuffle": self._shuffle,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
             "port": self.port,
@@ -533,6 +543,7 @@ class AppState:
         self._pause_time = data.get("pause_time", 0)
         self._clear_pattern = data.get("clear_pattern", "none")
         self._clear_pattern_speed = data.get("clear_pattern_speed", None)
+        self._shuffle = data.get("shuffle", False)
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.port = data.get("port", None)

+ 35 - 6
modules/mqtt/handler.py

@@ -3,11 +3,10 @@ import os
 import threading
 import time
 import json
-from typing import Dict, Callable, List, Optional, Any
+from typing import Dict, Callable
 import paho.mqtt.client as mqtt
 import logging
 import asyncio
-from functools import partial
 
 from .base import BaseMQTTHandler
 from modules.core.state import state
@@ -122,7 +121,7 @@ class MQTTHandler(BaseMQTTHandler):
 
         # Stop Button
         stop_config = {
-            "name": f"Stop pattern execution",
+            "name": "Stop pattern execution",
             "unique_id": f"{self.device_id}_stop",
             "command_topic": f"{self.device_id}/command/stop",
             "device": base_device,
@@ -133,7 +132,7 @@ class MQTTHandler(BaseMQTTHandler):
 
         # Pause Button
         pause_config = {
-            "name": f"Pause pattern execution",
+            "name": "Pause pattern execution",
             "unique_id": f"{self.device_id}_pause",
             "command_topic": f"{self.device_id}/command/pause",
             "state_topic": f"{self.device_id}/command/pause/state",
@@ -151,7 +150,7 @@ class MQTTHandler(BaseMQTTHandler):
 
         # Play Button
         play_config = {
-            "name": f"Resume pattern execution",
+            "name": "Resume pattern execution",
             "unique_id": f"{self.device_id}_play",
             "command_topic": f"{self.device_id}/command/play",
             "state_topic": f"{self.device_id}/command/play/state",
@@ -248,6 +247,20 @@ class MQTTHandler(BaseMQTTHandler):
         }
         self._publish_discovery("select", "clear_pattern", clear_pattern_config)
 
+        # Shuffle Switch
+        shuffle_config = {
+            "name": f"{self.device_name} Shuffle",
+            "unique_id": f"{self.device_id}_shuffle",
+            "command_topic": f"{self.device_id}/playlist/shuffle/set",
+            "state_topic": f"{self.device_id}/playlist/shuffle/state",
+            "payload_on": "ON",
+            "payload_off": "OFF",
+            "device": base_device,
+            "icon": "mdi:shuffle-variant",
+            "entity_category": "config"
+        }
+        self._publish_discovery("switch", "shuffle", shuffle_config)
+
         # Completion Percentage Sensor
         completion_config = {
             "name": f"{self.device_name} Completion",
@@ -448,6 +461,14 @@ class MQTTHandler(BaseMQTTHandler):
             self.client.publish(self.completion_topic, 0, retain=True)
             self.client.publish(self.time_remaining_topic, 0, retain=True)
 
+    def _publish_playlist_settings_state(self):
+        """Helper to publish playlist settings state (mode, pause_time, clear_pattern, shuffle)."""
+        self.client.publish(f"{self.device_id}/playlist/mode/state", state.playlist_mode, retain=True)
+        self.client.publish(f"{self.device_id}/playlist/pause_time/state", state.pause_time, retain=True)
+        self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", state.clear_pattern, retain=True)
+        shuffle_state = "ON" if state.shuffle else "OFF"
+        self.client.publish(f"{self.device_id}/playlist/shuffle/state", shuffle_state, retain=True)
+
     def _publish_led_state(self):
         """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
         if not state.led_controller or state.led_provider != "dw_leds":
@@ -544,6 +565,7 @@ class MQTTHandler(BaseMQTTHandler):
                 (f"{self.device_id}/playlist/mode/set", 0),
                 (f"{self.device_id}/playlist/pause_time/set", 0),
                 (f"{self.device_id}/playlist/clear_pattern/set", 0),
+                (f"{self.device_id}/playlist/shuffle/set", 0),
                 (self.led_power_topic, 0),
                 (self.led_brightness_topic, 0),
                 (self.led_effect_topic, 0),
@@ -599,7 +621,8 @@ class MQTTHandler(BaseMQTTHandler):
                             playlist_name=playlist_name,
                             run_mode=self.state.playlist_mode,
                             pause_time=self.state.pause_time,
-                            clear_pattern=self.state.clear_pattern
+                            clear_pattern=self.state.clear_pattern,
+                            shuffle=self.state.shuffle
                         ),
                         self.main_loop
                     ).add_done_callback(
@@ -652,6 +675,11 @@ class MQTTHandler(BaseMQTTHandler):
                 if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
                     state.clear_pattern = clear_pattern
                     self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
+            elif msg.topic == f"{self.device_id}/playlist/shuffle/set":
+                payload = msg.payload.decode()
+                shuffle_value = payload == "ON"
+                state.shuffle = shuffle_value
+                self.client.publish(f"{self.device_id}/playlist/shuffle/state", payload, retain=True)
             elif msg.topic == self.led_power_topic:
                 # Handle LED power command (DW LEDs only)
                 payload = msg.payload.decode()
@@ -798,6 +826,7 @@ class MQTTHandler(BaseMQTTHandler):
             self._publish_playlist_state()
             self._publish_serial_state()
             self._publish_progress_state()
+            self._publish_playlist_settings_state()
             self._publish_led_state()
 
             # Setup Home Assistant discovery