mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user