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,
     so the request is rejected rather than sending M106 P10 into the void on a
     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_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.
                 parts = airduct_data.get("parts")
                 if isinstance(parts, list):
-                    left_aux_speed = None
-                    exhaust_present = False
+                    speeds: dict[int, int] = {}
                     for part in parts:
                         if not isinstance(part, dict):
                             continue
@@ -3576,20 +3575,45 @@ class BambuMQTTClient:
                         # Ids seen across the support-package archive:
                         #   1 part cooling, 2 aux, 3 chamber/exhaust,
                         #   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:
                         logger.debug(
                             f"[{self.serial_number}] left_aux_fan_speed changed: "
                             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
-                    # 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
                 # Check if we recently set the target locally (within 5 seconds)
                 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
 
 
+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:
     """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
                 ? t('printers.fans.exhaust')
                 : 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: {
                 key: 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 -->
     <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>
   <body>
     <div id="root"></div>

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