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.
This commit is contained in:
gzimbric
2026-07-29 11:33:09 -05:00
parent f2babc9027
commit b938b83136
5 changed files with 131 additions and 5 deletions
+14 -2
View File
@@ -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
View File
@@ -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:
@@ -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(
@@ -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"
)
@@ -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,
)