Преглед изворни кода

fix(printers): use the model-appropriate name in the fan-speed response

The fan-speed endpoint always reported 'Chamber fan set to N%', so on
P2S/X2D — where the printer card labels that fan 'Exhaust' — clicking
Exhaust produced a toast saying Chamber fan.

Adds uses_exhaust_fan_label() to printer_models so the badge label and the
API response share one source of truth, and uses it to pick 'Exhaust fan'
vs 'Chamber fan' in the response message.

Tests: helper coverage for P2S/X2D (incl. internal codes N7/N6), other
enclosed models, and unknown/missing model; API test asserting the message
matches the badge label per model.
Gabe пре 1 месец
родитељ
комит
15ec0bf1c5

+ 6 - 1
backend/app/api/routes/printers.py

@@ -3226,11 +3226,16 @@ async def set_fan_speed(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to set fan speed")
         raise HTTPException(500, "Failed to set fan speed")
 
 
+    # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
+    # match whatever the printer card badge shows so the toast agrees with the
+    # control the user just clicked.
+    from backend.app.utils.printer_models import uses_exhaust_fan_label
+
     fan_names = {
     fan_names = {
         "part": "Part cooling fan",
         "part": "Part cooling fan",
         "aux": "Auxiliary fan",
         "aux": "Auxiliary fan",
         "aux2": "Left auxiliary fan",
         "aux2": "Left auxiliary fan",
-        "chamber": "Chamber fan",
+        "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
     }
     }
     return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
     return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
 
 

+ 30 - 0
backend/app/utils/printer_models.py

@@ -212,6 +212,36 @@ DUAL_NOZZLE_MODELS = frozenset(
 )
 )
 
 
 
 
+# Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 /
+# airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's
+# touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it
+# is an add-on kit rather than built-in hardware. Other enclosed models
+# (X1 / P1S / H2 series) keep the "Chamber" naming.
+EXHAUST_FAN_LABEL_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "P2S",
+        "X2D",
+        # Internal codes
+        "N7",  # P2S
+        "N6",  # X2D
+    ]
+)
+
+
+def uses_exhaust_fan_label(model: str | None) -> bool:
+    """Return True if this model calls the big_fan2 enclosure fan "Exhaust".
+
+    P2S/X2D name that fan "Exhaust" in Bambu's firmware/UI; everything else
+    enclosed calls it the chamber fan. Used so the UI badge and the API
+    response message agree on what the user sees.
+    """
+    if not model:
+        return False
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized in EXHAUST_FAN_LABEL_MODELS
+
+
 def has_ethernet(model: str | None) -> bool:
 def has_ethernet(model: str | None) -> bool:
     """Return True if the printer model has an ethernet port."""
     """Return True if the printer model has an ethernet port."""
     if not model:
     if not model:

+ 30 - 0
backend/tests/integration/test_printers_api.py

@@ -3913,6 +3913,36 @@ class TestSetFanSpeedAPI:
         _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         assert called_pwm == expected_pwm
         assert called_pwm == expected_pwm
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "model,expected_label",
+        [
+            ("P2S", "Exhaust fan"),
+            ("X2D", "Exhaust fan"),
+            ("X1C", "Chamber fan"),
+            ("P1S", "Chamber fan"),
+            ("H2D", "Chamber fan"),
+        ],
+    )
+    async def test_chamber_fan_message_matches_model_label(
+        self, async_client: AsyncClient, printer_factory, model, expected_label
+    ):
+        """The success toast must use the same name as the printer card badge.
+
+        On P2S/X2D the big_fan2 fan is labelled "Exhaust"; everywhere else it
+        stays "Chamber". A mismatch means the user clicks "Exhaust" and gets
+        told "Chamber fan set to N%".
+        """
+        printer = await printer_factory(name="P", model=model)
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=chamber&speed=50")
+        assert response.status_code == 200
+        assert response.json()["message"] == f"{expected_label} set to 50%"
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
     async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):

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

@@ -205,3 +205,26 @@ class TestLeftAuxFanCommand:
         for idx in (1, 2, 3):
         for idx in (1, 2, 3):
             assert mqtt_client.set_fan_speed(idx, 128) is True
             assert mqtt_client.set_fan_speed(idx, 128) is True
         assert sent == ["M106 P1 S128", "M106 P2 S128", "M106 P3 S128"]
         assert sent == ["M106 P1 S128", "M106 P2 S128", "M106 P3 S128"]
+
+
+class TestExhaustFanLabelModels:
+    """P2S/X2D call the big_fan2 enclosure fan "Exhaust"; others say "Chamber"."""
+
+    def test_p2s_and_x2d_use_exhaust_label(self):
+        from backend.app.utils.printer_models import uses_exhaust_fan_label
+
+        for model in ("P2S", "X2D", "p2s", " P2S ", "N7", "N6"):
+            assert uses_exhaust_fan_label(model) is True, model
+
+    def test_other_enclosed_models_keep_chamber_label(self):
+        from backend.app.utils.printer_models import uses_exhaust_fan_label
+
+        for model in ("X1C", "X1", "X1E", "P1S", "H2D", "H2C", "H2S", "A1"):
+            assert uses_exhaust_fan_label(model) is False, model
+
+    def test_unknown_or_missing_model_defaults_to_chamber(self):
+        from backend.app.utils.printer_models import uses_exhaust_fan_label
+
+        assert uses_exhaust_fan_label(None) is False
+        assert uses_exhaust_fan_label("") is False
+        assert uses_exhaust_fan_label("SomeFutureModel") is False