diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 8f5de7859..fdbd6d4eb 100644 --- a/backend/app/api/routes/printers.py +++ b/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: diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 694d2be32..d9bd2b882 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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: diff --git a/backend/tests/integration/test_printers_api.py b/backend/tests/integration/test_printers_api.py index 0803026b9..2dcbe9ded 100644 --- a/backend/tests/integration/test_printers_api.py +++ b/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( diff --git a/backend/tests/unit/services/test_p2s_accessory_fans.py b/backend/tests/unit/services/test_p2s_accessory_fans.py index aa949c3a3..3bf57d26e 100644 --- a/backend/tests/unit/services/test_p2s_accessory_fans.py +++ b/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" + ) diff --git a/backend/tests/unit/test_plate_clear_mqtt_notification.py b/backend/tests/unit/test_plate_clear_mqtt_notification.py index cec777948..84a3613cd 100644 --- a/backend/tests/unit/test_plate_clear_mqtt_notification.py +++ b/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, )