Просмотр исходного кода

fix(tests): add the new fan fields to the plate-clear status fixture

mqtt_relay reads state.left_aux_fan_speed, but the SimpleNamespace fixture in
test_plate_clear_mqtt_notification enumerates its fields explicitly, so the two
status-payload tests raised AttributeError. I updated the equivalent fixture in
test_printer_manager_status_broadcast and missed this one — running only the
touched suites is what hid it.

Also addresses the round-2 review notes:

- exhaust_fan_present: documented that the H2 series reports part 3 too, so the
  flag is not model-specific despite the name.
- Mask the part id after shifting, matching get_flag_bits(id, 4, 8), for
  consistency with the state decode. No behaviour change for any observed id.
- Noted the unmapped H2 id 6 beside the id branches.
- Reject fan=aux2 when the printer reports no left_aux_fan_speed, so a POST
  against an A1 no longer sends M106 P10 for absent hardware. The UI already
  hid the badge; this closes the same hole on the API.
- Added a test asserting EXHAUST_FAN_LABEL_MODELS and the frontend's
  MODELS_WITH_EXHAUST_LABEL cannot drift apart.
gzimbric 1 месяц назад
Родитель
Сommit
b938b83136

+ 14 - 2
backend/app/api/routes/printers.py

@@ -3205,8 +3205,9 @@ async def set_fan_speed(
 
     Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
     P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
-    profile gcode does. It only exists when the accessory is installed; the
-    firmware silently ignores the command otherwise.
+    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.
     """
     fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
     fan_id = fan_ids.get(fan)
@@ -3222,6 +3223,17 @@ async def set_fan_speed(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
+    # Presence gate for the accessory fan. Without this, aux2 is accepted for
+    # every model and an A1 would be sent M106 P10 for a fan it does not have.
+    # The UI already hides the badge; this closes the same hole on the API.
+    if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
+        raise HTTPException(
+            400,
+            "This printer does not report a left auxiliary fan "
+            "(no airduct part 10). The fan is an accessory kit on the P2S "
+            "and factory-fitted on the X2D.",
+        )
+
     pwm_speed = round(speed * 255 / 100)
     success = client.set_fan_speed(fan_id, pwm_speed)
     if not success:

+ 18 - 3
backend/app/services/bambu_mqtt.py

@@ -477,8 +477,15 @@ class PrinterState:
     # Studio's AIR_FUN enum) — the firmware does NOT mirror it into any flat
     # big_fanX_speed field, which is why it was previously dropped. 0-100 percent.
     left_aux_fan_speed: int | None = None
-    # Chamber exhaust fan (P2S/X2D External Exhaust Fan kit). Presence is derived
-    # from the airduct parts list containing decoded id 3; a base P2S omits it.
+    # Chamber exhaust fan, derived from the airduct parts list containing decoded
+    # id 3. On the P2S this is the External Exhaust Fan kit and a base machine
+    # omits it, which is the case this flag exists to detect.
+    #
+    # NOTE: the flag is not P2S/X2D-specific despite the name. The H2 series
+    # (H2C/H2D/H2S) also reports part 3, so this goes True there too. That is
+    # harmless because only the P2S/X2D badge consults it — those models keep
+    # their unconditional "Chamber Fan" badge — but do not read this as
+    # "an exhaust kit is fitted" without also checking the model.
     exhaust_fan_present: bool = False
     # Tray change history during current print: [(global_tray_id, layer_num), ...]
     # Used by usage tracker to split filament weight on mid-print tray switch
@@ -3488,7 +3495,12 @@ class BambuMQTTClient:
                         if not isinstance(part, dict):
                             continue
                         try:
-                            part_id = int(part["id"]) >> 4
+                            # Studio reads the id with get_flag_bits(id, 4, 8),
+                            # so mask after shifting for the same reason `state`
+                            # is masked below. Every id seen in the wild
+                            # (16/32/48/160) decodes identically either way —
+                            # this is consistency, not a live bug.
+                            part_id = (int(part["id"]) >> 4) & 0xFF
                             # `state` is bit-packed like its sibling `range`
                             # (end << 16 | start), so take only the low 8 bits —
                             # the same decode Bambu Studio does with
@@ -3498,6 +3510,9 @@ class BambuMQTTClient:
                             part_state = int(part["state"]) & 0xFF
                         except (KeyError, ValueError, TypeError):
                             continue
+                        # 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:

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

@@ -3888,6 +3888,10 @@ class TestSetFanSpeedAPI:
         printer = await printer_factory(name="P", model="X1C")
         mock_client = MagicMock()
         mock_client.set_fan_speed.return_value = True
+        # aux2 is presence-gated on the printer reporting airduct part 10, so
+        # give the mock a reported speed. Set explicitly rather than leaning on
+        # MagicMock's auto-attribute, which would satisfy the gate by accident.
+        mock_client.state.left_aux_fan_speed = 0
         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={fan_name}&speed=100")
@@ -3895,6 +3899,39 @@ class TestSetFanSpeedAPI:
         called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         assert called_fan_id == expected_fan_id
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_aux2_rejected_when_printer_has_no_left_aux_fan(self, async_client: AsyncClient, printer_factory):
+        """A printer that never reports airduct part 10 must not be sent M106 P10.
+
+        Without the gate the endpoint accepted aux2 for every model, so a POST
+        against an A1 would fire a command for hardware that does not exist.
+        """
+        printer = await printer_factory(name="P", model="A1")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        mock_client.state.left_aux_fan_speed = None
+        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=aux2&speed=50")
+        assert response.status_code == 400
+        assert "left auxiliary fan" in response.json()["detail"]
+        mock_client.set_fan_speed.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_other_fans_unaffected_by_the_aux2_gate(self, async_client: AsyncClient, printer_factory):
+        """The gate is aux2-only — a base P2S can still drive its built-in fans."""
+        printer = await printer_factory(name="P", model="P2S")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        mock_client.state.left_aux_fan_speed = None
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            for fan_name in ("part", "aux", "chamber"):
+                response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=50")
+                assert response.status_code == 200, fan_name
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.parametrize(

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

@@ -247,3 +247,63 @@ class TestExhaustFanLabelModels:
         assert uses_exhaust_fan_label(None) is False
         assert uses_exhaust_fan_label("") is False
         assert uses_exhaust_fan_label("SomeFutureModel") is False
+
+
+class TestExhaustLabelModelListsAgree:
+    """The exhaust-label model list is duplicated across the stack.
+
+    The backend keeps ``EXHAUST_FAN_LABEL_MODELS`` (display names plus the N7/N6
+    internal codes, since the API can be handed either) and the frontend keeps
+    ``MODELS_WITH_EXHAUST_LABEL`` in PrintersPage.tsx (display names only —
+    ``printer.model`` is always a display name by the time it reaches the card).
+    Both are correct as written, but nothing stopped them drifting apart: adding
+    a model to one and forgetting the other silently produces a card labelled
+    "Exhaust" whose control toast says "Chamber fan", or vice versa.
+    """
+
+    def _frontend_models(self) -> set[str]:
+        import re
+        from pathlib import Path
+
+        import pytest
+
+        # Walk up rather than hard-coding a parent depth, so the test survives
+        # the file being moved and works whatever directory pytest runs from.
+        relative = Path("frontend") / "src" / "pages" / "PrintersPage.tsx"
+        source = next(
+            (candidate for parent in Path(__file__).resolve().parents if (candidate := parent / relative).is_file()),
+            None,
+        )
+        if source is None:
+            pytest.skip("frontend sources not present in this checkout")
+        text = source.read_text(encoding="utf-8")
+        match = re.search(
+            r"const MODELS_WITH_EXHAUST_LABEL:[^=]*=\s*new Set\(\[(.*?)\]\)",
+            text,
+            re.DOTALL,
+        )
+        assert match, "MODELS_WITH_EXHAUST_LABEL not found in PrintersPage.tsx"
+        return set(re.findall(r"['\"]([^'\"]+)['\"]", match.group(1)))
+
+    def test_frontend_list_is_the_display_name_subset_of_the_backend_list(self):
+        from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
+
+        frontend = self._frontend_models()
+        assert frontend, "frontend list parsed as empty"
+        missing = frontend - set(EXHAUST_FAN_LABEL_MODELS)
+        assert not missing, (
+            f"models {sorted(missing)} label the fan 'Exhaust' in the UI but the backend "
+            f"would report 'Chamber fan' — add them to EXHAUST_FAN_LABEL_MODELS"
+        )
+
+    def test_every_backend_display_name_is_handled_by_the_frontend(self):
+        from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
+
+        # N7/N6 are internal codes that never reach the card, so exclude them.
+        internal_codes = {"N7", "N6"}
+        backend_display = set(EXHAUST_FAN_LABEL_MODELS) - internal_codes
+        missing = backend_display - self._frontend_models()
+        assert not missing, (
+            f"models {sorted(missing)} say 'Exhaust fan' in the API response but the card "
+            f"would still show 'Chamber Fan' — add them to MODELS_WITH_EXHAUST_LABEL"
+        )

+ 2 - 0
backend/tests/unit/test_plate_clear_mqtt_notification.py

@@ -44,6 +44,8 @@ def _state() -> SimpleNamespace:
         big_fan1_speed=0,
         big_fan2_speed=0,
         heatbreak_fan_speed=0,
+        left_aux_fan_speed=None,
+        exhaust_fan_present=False,
     )