ソースを参照

fix(mqtt): an H2D's active slot is read against the same message's nozzle data, and older nozzle data no longer overrules it

maziggy 4 日 前
親
コミット
e3824097fa

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **An H2D shows the right active slot after a filament change** — An H2D reports its active slot as a bare number and names the AMS in each nozzle's own data, often in the same message. That message's AMS part was read first, against the previous message's nozzle data, so after a swap the card (and usage tracking) could keep pointing at the old slot until the printer reported its slot again. The nozzle data is now read first, and nozzle data from an earlier message no longer overrules the slot the printer just reported.
 - **Home Assistant "Run when printer turns off" scripts run when Bambuddy switches the printer off** — Linked scripts ran only when someone switched the printer's plug by hand. Auto-off after a print or drying (also when resumed after a restart), the plug's schedule and AI failure detection turned the printer off without them, and the schedule turned it on without its "on" scripts. They now run on all of these. "On" scripts keep running at every print start as before. For these automatic switches only a plug that powers the printer counts, the scripts run once when two plugs feed the printer, and a script whose **Enabled** toggle is off does not run (by hand either). Failure detection no longer tries to switch off a script.
 - **A plate check that could not run says so** — When the camera gave no image, the analysis failed or there was no calibration to compare against, the check before a print reported an empty plate, logged "Plate is empty" and told nobody. The print still goes ahead, but users who can control printers now get a warning with the reason, the log records it, and **Check Plate** shows it instead of "Plate appears empty".
 - **The archive recovery download gives a large 3MF time** — The retry that fetches a print's 3MF when the first download failed gave up after a flat 90 seconds, so a large file on a slow SD card never made it. Once the printer reports the file's size, the deadline follows it (up to 15 minutes), as the first download's already did.

+ 105 - 53
backend/app/services/bambu_mqtt.py

@@ -1549,6 +1549,9 @@ class BambuMQTTClient:
         # H2D only reports slot number (0-3) in tray_now, not global tray ID
         # We use our tracked value to resolve the correct global ID
         self._last_load_tray_id: int | None = None
+        # Whether the frame being handled carried the nozzles' snow: only then
+        # may snow overrule the slot the same frame reports as tray_now.
+        self._snow_in_frame = False
 
         # The captured ams_mapping lives on the state (print_ams_mapping), so
         # the status the card reads carries it; see _captured_ams_mapping.
@@ -2308,6 +2311,7 @@ class BambuMQTTClient:
         # Wrap in try/except to prevent breaking the MQTT connection
         if "ams" in payload:
             try:
+                self._snow_in_frame = False  # a bare AMS frame carries no extruder data
                 self._handle_ams_data(payload["ams"])
             except Exception as e:
                 logger.error("[%s] Error handling AMS data: %s", self.serial_number, e)
@@ -2446,6 +2450,11 @@ class BambuMQTTClient:
             # the binding on every frame where the two arrive together.
             self._parse_fila_switch(print_data)
 
+            # Before _handle_ams_data too: an H2D's tray_now is a bare slot that
+            # this frame's extruder data places (which nozzle, which AMS).
+            self._snow_in_frame = self._parse_extruder_position(print_data)
+            self._parse_extruder_slots(print_data)
+
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
                 try:
@@ -3146,6 +3155,81 @@ class BambuMQTTClient:
             info=int(fs_data.get("info", 0) or 0),
         )
 
+    def _parse_extruder_position(self, data: dict) -> bool:
+        """Read which nozzle is active and which tray each nozzle holds.
+
+        From ``device.extruder``: ``state`` bit 8 is the active nozzle, each
+        ``info`` entry's ``snow`` names its AMS and slot. Run on a frame before
+        its AMS block, because an H2D's tray_now is a bare slot that only the
+        extruder data of the same frame places. Returns True when the frame
+        carried the active nozzle's snow. A malformed value is skipped, never
+        raised: this runs ahead of everything else the frame carries.
+        """
+        device = data.get("device")
+        extruder = device.get("extruder") if isinstance(device, dict) else None
+        if not isinstance(extruder, dict):
+            return False
+        try:
+            state_val = int(extruder["state"]) if "state" in extruder else None
+        except (TypeError, ValueError):
+            state_val = None
+        if state_val is not None:
+            # Extract bit 8 for extruder position
+            new_extruder = (state_val >> 8) & 0x1
+            if new_extruder != self.state.active_extruder:
+                logger.debug(
+                    f"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]"
+                )
+                self.state.active_extruder = new_extruder
+        extruder_info = extruder.get("info")
+        if not isinstance(extruder_info, list) or len(extruder_info) < 2:
+            return False
+        # Parse H2D snow field (slot now) for accurate tray_now disambiguation
+        # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
+        snow_read: set[int] = set()
+        for ext_info in extruder_info:
+            if not isinstance(ext_info, dict) or "id" not in ext_info or "snow" not in ext_info:
+                continue
+            try:
+                ext_id = int(ext_info["id"])
+                snow = int(ext_info["snow"])
+            except (TypeError, ValueError):
+                continue
+            if ext_id <= 1:
+                snow_read.add(ext_id)
+                # Normalize H2D snow value to global tray ID
+                ams_id = snow >> 8
+                slot = snow & 0xFF
+                if 0 <= ams_id <= 3:
+                    # Regular AMS slot
+                    global_tray = ams_id * 4 + (slot & 0x03)
+                    old_val = self.state.h2d_extruder_snow.get(ext_id)
+                    if old_val != global_tray:
+                        logger.debug(
+                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
+                            f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}"
+                        )
+                    self.state.h2d_extruder_snow[ext_id] = global_tray
+                elif ams_id == 254 or ams_id == 255:
+                    # External spool or unloaded
+                    normalized = 254 if slot != 255 else 255
+                    old_val = self.state.h2d_extruder_snow.get(ext_id)
+                    if old_val != normalized:
+                        logger.debug(
+                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
+                            f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}"
+                        )
+                    self.state.h2d_extruder_snow[ext_id] = normalized
+                elif 128 <= ams_id <= 135:
+                    # External spool with hub mapping
+                    old_val = self.state.h2d_extruder_snow.get(ext_id)
+                    if old_val != ams_id:
+                        logger.debug(
+                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: raw={snow} -> external hub {ams_id}"
+                        )
+                    self.state.h2d_extruder_snow[ext_id] = ams_id
+        return self.state.active_extruder in snow_read
+
     def _parse_extruder_slots(self, data: dict) -> None:
         """Read which AMS slot each extruder is fed from — ``device.extruder.info``.
 
@@ -3312,11 +3396,23 @@ class BambuMQTTClient:
 
                         # Best source: use snow value from device.extruder.info if available
                         snow_tray = self.state.h2d_extruder_snow.get(active_ext)
-                        if snow_tray is not None and snow_tray != 255:
+                        # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0
+                        snow_slot = (
+                            None
+                            if snow_tray is None
+                            else snow_tray % 4
+                            if snow_tray < 128
+                            else (0 if snow_tray <= 135 else -1)
+                        )
+                        # A snow from an earlier frame may predate a swap: it
+                        # names the tray only while it agrees with the slot.
+                        snow_usable = (
+                            snow_tray is not None
+                            and snow_tray != 255
+                            and (snow_slot == parsed_tray_now or self._snow_in_frame)
+                        )
+                        if snow_usable and snow_tray is not None:
                             # snow_tray is already normalized to global ID
-                            # Verify the slot matches what we see in tray_now
-                            # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0
-                            snow_slot = snow_tray % 4 if snow_tray < 128 else (0 if snow_tray <= 135 else -1)
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
                                     logger.debug(
@@ -3325,7 +3421,8 @@ class BambuMQTTClient:
                                     )
                                 self.state.tray_now = snow_tray
                             else:
-                                # Slot mismatch - snow field may not have updated yet, trust snow
+                                # Slot mismatch in one frame: the extruder's own
+                                # report is the complete answer, trust snow
                                 logger.debug(
                                     f"[{self.serial_number}] H2D tray_now: ams.tray_now slot {parsed_tray_now} "
                                     f"!= snow slot {snow_slot}, using snow value {snow_tray}"
@@ -4572,15 +4669,7 @@ class BambuMQTTClient:
                         sorted(device.keys()),
                     )
                 self._device_id_logged = True
-            if "extruder" in device and "state" in device["extruder"]:
-                state_val = device["extruder"]["state"]
-                # Extract bit 8 for extruder position
-                new_extruder = (state_val >> 8) & 0x1
-                if new_extruder != self.state.active_extruder:
-                    logger.debug(
-                        f"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]"
-                    )
-                    self.state.active_extruder = new_extruder
+            self._parse_extruder_position(data)
 
         # Log device.extruder structure for active extruder
         if "device" in data and isinstance(data.get("device"), dict):
@@ -4802,45 +4891,8 @@ class BambuMQTTClient:
                             if not respect_local_target:
                                 temps["nozzle_target"] = 0.0
                             temps["nozzle_heating"] = False  # Direct = not heating
-                    # Parse H2D snow field (slot now) for accurate tray_now disambiguation
-                    # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
-                    if has_h2d_extruder_info:
-                        for ext_info in extruder_info:
-                            ext_id = ext_info.get("id")
-                            snow = ext_info.get("snow")
-                            if ext_id is not None and snow is not None and ext_id <= 1:
-                                # Normalize H2D snow value to global tray ID
-                                ams_id = snow >> 8
-                                slot = snow & 0xFF
-                                if 0 <= ams_id <= 3:
-                                    # Regular AMS slot
-                                    global_tray = ams_id * 4 + (slot & 0x03)
-                                    old_val = self.state.h2d_extruder_snow.get(ext_id)
-                                    if old_val != global_tray:
-                                        logger.debug(
-                                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
-                                            f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}"
-                                        )
-                                    self.state.h2d_extruder_snow[ext_id] = global_tray
-                                elif ams_id == 254 or ams_id == 255:
-                                    # External spool or unloaded
-                                    normalized = 254 if slot != 255 else 255
-                                    old_val = self.state.h2d_extruder_snow.get(ext_id)
-                                    if old_val != normalized:
-                                        logger.debug(
-                                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
-                                            f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}"
-                                        )
-                                    self.state.h2d_extruder_snow[ext_id] = normalized
-                                elif 128 <= ams_id <= 135:
-                                    # External spool with hub mapping
-                                    old_val = self.state.h2d_extruder_snow.get(ext_id)
-                                    if old_val != ams_id:
-                                        logger.debug(
-                                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
-                                            f"raw={snow} -> external hub {ams_id}"
-                                        )
-                                    self.state.h2d_extruder_snow[ext_id] = ams_id
+                    # The extruder snow is read by _parse_extruder_position, before the
+                    # AMS block of the same frame (tray_now resolution needs it first).
                 # Parse bed heating state from device.bed.info.temp encoding
                 # temp > 500 means encoded (target*65536+current), heating = target > 0 AND current < target
                 bed_data = device.get("bed", {})

+ 2 - 2
backend/tests/unit/services/test_bambu_mqtt.py

@@ -2932,8 +2932,8 @@ class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
 
     def test_snow_disambiguates_ams0_slot(self, h2d_client):
         """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
-        # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
-        # so we need it in a prior message).
+        # Snow in a prior message here; a frame carrying both is covered in
+        # test_h2d_tray_now_same_frame_g11.py.
         snow_val = 0 << 8 | 2  # AMS 0 slot 2 = raw 2
         h2d_client._process_message(
             _extruder_info_payload(

+ 125 - 0
backend/tests/unit/test_h2d_tray_now_same_frame_g11.py

@@ -0,0 +1,125 @@
+"""H2D active tray: the extruder block of a frame is read before its AMS block.
+
+An H2D reports tray_now as a bare slot (0-3) and names the AMS in each
+extruder's ``snow``. Both often arrive in one frame, but the AMS block was
+handled first, so tray_now was resolved against the previous frame's snow --
+and on a disagreement that stale snow won. Now a frame's snow is read first,
+and a snow from an earlier frame no longer overrides the slot the printer just
+reported: the AMS-to-nozzle map places it instead.
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _frame(*, tray_now=None, snow_right=None, state=0x0000):
+    print_data: dict = {}
+    if snow_right is not None:
+        print_data["device"] = {
+            "extruder": {
+                "info": [{"id": 0, "snow": snow_right}, {"id": 1, "snow": 0xFFFF}],
+                "state": state,
+            }
+        }
+    if tray_now is not None:
+        print_data["ams"] = {"tray_now": str(tray_now)}
+    return {"print": print_data}
+
+
+@pytest.fixture
+def h2d():
+    """H2D, right nozzle active, AMS 0 on the right nozzle, AMS-HT 128 on the left."""
+    client = BambuMQTTClient(ip_address="10.0.0.2", serial_number="TEST_H2D_G11", access_code="x")
+    client._process_message(
+        {
+            "print": {
+                "device": {"extruder": {"info": [{"id": 0, "snow": 0xFFFF}, {"id": 1, "snow": 0xFFFF}], "state": 0}},
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+                        {"id": 128, "info": "2104", "tray": [{"id": 0}]},
+                    ],
+                    "tray_now": "255",
+                },
+            }
+        }
+    )
+    assert client._is_dual_nozzle
+    return client
+
+
+def test_a_frame_carrying_both_uses_its_own_snow(h2d):
+    h2d._process_message(_frame(tray_now=0, snow_right=0x0000))  # AMS 0 slot 0
+    assert h2d.state.tray_now == 0
+
+    h2d._process_message(_frame(tray_now=2, snow_right=0x0002))  # swap to AMS 0 slot 2
+
+    assert h2d.state.tray_now == 2
+
+
+def test_a_stale_snow_does_not_override_the_reported_slot(h2d):
+    h2d._process_message(_frame(tray_now=0, snow_right=0x0000))
+
+    h2d._process_message(_frame(tray_now=2))  # no extruder block in this frame
+
+    # One AMS on the active nozzle: the map places slot 2 there.
+    assert h2d.state.tray_now == 2
+
+
+def test_a_fresh_snow_still_wins_a_disagreement(h2d):
+    """The extruder's own report of its slot is the newer, complete answer."""
+    h2d._process_message(_frame(tray_now=0, snow_right=0x0003))
+
+    assert h2d.state.tray_now == 3
+
+
+def test_extruder_info_without_snow_is_not_a_fresh_snow(h2d):
+    """A temperature-only update of the extruder block names no tray."""
+    h2d._process_message(_frame(tray_now=0, snow_right=0x0000))
+
+    h2d._process_message(
+        {
+            "print": {
+                "device": {"extruder": {"info": [{"id": 0, "temp": 200}, {"id": 1, "temp": 30}], "state": 0}},
+                "ams": {"tray_now": "2"},
+            }
+        }
+    )
+
+    assert h2d.state.tray_now == 2
+
+
+def test_a_nozzle_switch_in_the_same_frame_uses_the_new_nozzle(h2d):
+    """Left nozzle (bit 8) feeds from the AMS-HT 128, whose one tray is slot 0."""
+    h2d._process_message(
+        {
+            "print": {
+                "device": {
+                    "extruder": {"info": [{"id": 0, "snow": 0x0002}, {"id": 1, "snow": 128 << 8}], "state": 0x100}
+                },
+                "ams": {"tray_now": "0"},
+            }
+        }
+    )
+
+    assert h2d.state.active_extruder == 1
+    assert h2d.state.tray_now == 128
+
+
+@pytest.mark.parametrize(
+    "info", [[{"id": 0, "snow": "x2"}, {"id": 1, "snow": 65535}], ["junk", {"id": "x", "snow": 1}]]
+)
+def test_malformed_extruder_data_does_not_lose_the_frame(h2d, info):
+    h2d._process_message(
+        {
+            "print": {
+                "device": {"extruder": {"info": info, "state": 0}},
+                "ams": {"tray_now": "1"},
+                "gcode_state": "RUNNING",
+            }
+        }
+    )
+
+    assert h2d.state.state == "RUNNING"
+    assert h2d.state.tray_now == 1