Sfoglia il codice sorgente

fix(printers): don't retract a fan kit on a partial airduct frame

device.airduct is pushed field by field - the modeCur handler reads it with
an "in" check for that reason - so a frame can carry parts without carrying
every fan. Absence in that list is what tells us a kit is not fitted, and
taken from a truncated frame it made both accessory badges vanish mid-print
and started rejecting fan=aux2 on a printer that has the fan.

A parts list now counts as a full inventory only when it carries ids 1 (part
cooling) and 2 (aux). Neither is optional on a machine that reports an
airduct at all, and both appear in every layout in the support-package
archive - P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6.
Anything narrower is a diff frame: its speeds are applied, presence is left
alone. Presence can still be added from a partial frame; only retraction
needs the full list, so a kit that really is removed still disappears.

Also compose showChamberFan from both model lists rather than branching
between them, so the P2S/X2D entries in MODELS_WITH_CHAMBER_FAN stay
reachable instead of reading as dead, and note in the fan-speed docstring
that the aux2 gate also rejects between connect and the first airduct push.
maziggy 1 mese fa
parent
commit
ac3e3cc60f

+ 5 - 0
backend/app/api/routes/printers.py

@@ -3208,6 +3208,11 @@ async def set_fan_speed(
     profile gcode does. It only exists when the printer reports airduct part 10,
     profile gcode does. It only exists when the printer reports airduct part 10,
     so the request is rejected rather than sending M106 P10 into the void on a
     so the request is rejected rather than sending M106 P10 into the void on a
     machine that has no such fan.
     machine that has no such fan.
+
+    That gate also rejects for the short window between connecting and the
+    first airduct push, when nothing is known about the fan yet. The card hides
+    the badge over the same window, so there is no control to click; a direct
+    API caller gets a 400 and should retry once the status reports the fan.
     """
     """
     fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
     fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
     fan_id = fan_ids.get(fan)
     fan_id = fan_ids.get(fan)

+ 34 - 10
backend/app/services/bambu_mqtt.py

@@ -3552,8 +3552,7 @@ class BambuMQTTClient:
                 # `state` is already a 0-100 percentage.
                 # `state` is already a 0-100 percentage.
                 parts = airduct_data.get("parts")
                 parts = airduct_data.get("parts")
                 if isinstance(parts, list):
                 if isinstance(parts, list):
-                    left_aux_speed = None
-                    exhaust_present = False
+                    speeds: dict[int, int] = {}
                     for part in parts:
                     for part in parts:
                         if not isinstance(part, dict):
                         if not isinstance(part, dict):
                             continue
                             continue
@@ -3576,20 +3575,45 @@ class BambuMQTTClient:
                         # Ids seen across the support-package archive:
                         # Ids seen across the support-package archive:
                         #   1 part cooling, 2 aux, 3 chamber/exhaust,
                         #   1 part cooling, 2 aux, 3 chamber/exhaust,
                         #   6 (H2 series, unmapped), 10 left aux.
                         #   6 (H2 series, unmapped), 10 left aux.
-                        if part_id == 10:
-                            left_aux_speed = max(0, min(100, part_state))
-                        elif part_id == 3:
-                            exhaust_present = True
+                        speeds[part_id] = max(0, min(100, part_state))
+
+                    # Absence in this list is what tells us a kit is NOT fitted,
+                    # so it may only be trusted when the list is a full
+                    # inventory rather than a diff frame. `device.airduct` is
+                    # pushed field by field — the `modeCur` handler above exists
+                    # for exactly that reason — and a truncated `parts` read as
+                    # gospel would retract both accessory badges mid-print and
+                    # start rejecting `aux2` on a printer that has the fan.
+                    #
+                    # Every airduct layout in the support-package archive
+                    # (P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 /
+                    # H2C,H2D,H2S 1,2,3,6 — 37 of 37 bundles) contains both the
+                    # part cooling fan and the aux fan, neither of which is
+                    # optional on any machine that reports an airduct at all.
+                    # A list carrying both is therefore a complete inventory; a
+                    # list missing either is a partial frame, and we take its
+                    # speeds without touching presence.
+                    is_full_inventory = 1 in speeds and 2 in speeds
+
+                    left_aux_speed = speeds.get(10)
+                    if left_aux_speed is None and not is_full_inventory:
+                        # Partial frame that didn't mention the left aux fan —
+                        # keep whatever we already knew about it.
+                        left_aux_speed = self.state.left_aux_fan_speed
                     if left_aux_speed != self.state.left_aux_fan_speed:
                     if left_aux_speed != self.state.left_aux_fan_speed:
                         logger.debug(
                         logger.debug(
                             f"[{self.serial_number}] left_aux_fan_speed changed: "
                             f"[{self.serial_number}] left_aux_fan_speed changed: "
                             f"{self.state.left_aux_fan_speed} -> {left_aux_speed}"
                             f"{self.state.left_aux_fan_speed} -> {left_aux_speed}"
                         )
                         )
-                    # A full parts list without id 10 means the left aux fan is not
-                    # installed — report None so the UI can hide the widget.
+                    # A FULL parts list without id 10 means the left aux fan is
+                    # not installed — report None so the UI can hide the widget.
                     self.state.left_aux_fan_speed = left_aux_speed
                     self.state.left_aux_fan_speed = left_aux_speed
-                    # id 3 present == chamber exhaust fan installed (base P2S omits it).
-                    self.state.exhaust_fan_present = exhaust_present
+                    # id 3 present == chamber exhaust fan installed (base P2S
+                    # omits it). Only ever retracted on a full inventory.
+                    if 3 in speeds:
+                        self.state.exhaust_fan_present = True
+                    elif is_full_inventory:
+                        self.state.exhaust_fan_present = False
                 # Parse chamber temp - may be encoded as (target*65536+current) when > 500
                 # Parse chamber temp - may be encoded as (target*65536+current) when > 500
                 # Check if we recently set the target locally (within 5 seconds)
                 # Check if we recently set the target locally (within 5 seconds)
                 local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0)
                 local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0)

+ 85 - 0
backend/tests/unit/services/test_p2s_accessory_fans.py

@@ -190,6 +190,91 @@ class TestExhaustFanPresence:
         assert mqtt_client.state.exhaust_fan_present is True
         assert mqtt_client.state.exhaust_fan_present is True
 
 
 
 
+class TestPartialPartsFrames:
+    """A `parts` list that is not a full inventory must not retract presence.
+
+    `device.airduct` is pushed field by field — the `modeCur` handler reads it
+    with an `in` check for exactly that reason — so a frame can carry `parts`
+    without carrying every fan. Absence is what tells us a kit is not fitted, so
+    it is only trustworthy on a complete list. Read as gospel, a truncated frame
+    would make both accessory badges vanish mid-print and start rejecting
+    ``fan=aux2`` on a printer that does have the fan.
+
+    Completeness is judged on ids 1 (part cooling) and 2 (aux) being present:
+    neither is optional on any machine that reports an airduct at all, and both
+    appear in every layout in the support-package archive (P2S base 1,2 /
+    P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6).
+    """
+
+    def test_partial_frame_does_not_retract_the_left_aux_fan(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+        # Only the part cooling fan changed — the frame says nothing about the
+        # left aux fan, which is not the same as saying it is gone.
+        mqtt_client._update_state(
+            _airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
+        )
+
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+    def test_partial_frame_does_not_retract_the_exhaust_fan(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.exhaust_fan_present is True
+
+        mqtt_client._update_state(
+            _airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
+        )
+
+        assert mqtt_client.state.exhaust_fan_present is True
+
+    def test_a_partial_frame_still_applies_the_speed_it_carries(self, mqtt_client):
+        """Not-authoritative-for-absence is not the same as ignored."""
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+        mqtt_client._update_state(
+            _airduct_device([{"func": 5, "id": 160, "range": 6553600, "state": 25, "tar_state": 25}])
+        )
+
+        assert mqtt_client.state.left_aux_fan_speed == 25
+
+    def test_a_partial_frame_can_still_reveal_a_fan(self, mqtt_client):
+        """Presence may always be added — only retraction needs a full list."""
+        mqtt_client._update_state(
+            _airduct_device([{"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70}])
+        )
+
+        assert mqtt_client.state.exhaust_fan_present is True
+
+    def test_a_full_frame_still_retracts_both(self, mqtt_client):
+        """The kits really can be removed, and a complete list must say so —
+        this is the behaviour the presence gate exists for."""
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+        assert mqtt_client.state.exhaust_fan_present is True
+
+        # Base P2S layout: part cooling + aux only.
+        mqtt_client._update_state(
+            _airduct_device(
+                [
+                    {"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
+                    {"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
+                ]
+            )
+        )
+
+        assert mqtt_client.state.left_aux_fan_speed is None
+        assert mqtt_client.state.exhaust_fan_present is False
+
+    def test_an_empty_parts_list_changes_nothing(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        mqtt_client._update_state(_airduct_device([]))
+
+        assert mqtt_client.state.left_aux_fan_speed == 80
+        assert mqtt_client.state.exhaust_fan_present is True
+
+
 class TestLeftAuxFanCommand:
 class TestLeftAuxFanCommand:
     """set_fan_speed must accept index 10 and emit M106 P10."""
     """set_fan_speed must accept index 10 and emit M106 P10."""
 
 

+ 7 - 1
frontend/src/pages/PrintersPage.tsx

@@ -3908,7 +3908,13 @@ function PrinterCard({
               const chamberFanLabel = isExhaustModel
               const chamberFanLabel = isExhaustModel
                 ? t('printers.fans.exhaust')
                 ? t('printers.fans.exhaust')
                 : t('printers.fans.chamber');
                 : t('printers.fans.chamber');
-              const showChamberFan = isExhaustModel ? status.exhaust_fan_present : hasChamberFan;
+              // Composed rather than either/or so both lists stay live for
+              // P2S/X2D: the model must have an enclosure fan at all, AND —
+              // where that fan is an add-on kit — actually report it. Written
+              // as `isExhaustModel ? exhaust_fan_present : hasChamberFan` the
+              // P2S/X2D entries in MODELS_WITH_CHAMBER_FAN became unreachable,
+              // which reads as if removing them were safe.
+              const showChamberFan = hasChamberFan && (!isExhaustModel || status.exhaust_fan_present);
               const fanItems: {
               const fanItems: {
                 key: string;
                 key: string;
                 label: string;
                 label: string;

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C2LOlVCR.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-D4bpNaiw.css


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-oReXTzKG.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-apAuCUp0.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
+    <script type="module" crossorigin src="/assets/index-C2LOlVCR.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff