bambuddy/backend/tests/unit/test_printer_models.py
maziggy 18938a10ee fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
and returned True, and the printer's extrusion_cali_set answer was
logged at DEBUG and dropped, so a write the printer refused was
reported to the user as saved (#2718, reporter @jmoore-skild).

The reason it could not simply be gated on: the answer itself was
wrong. Single-nozzle firmware returned result:"fail" with
reason:"invalid tray_id" on writes that demonstrably applied.
Measured against an X1C and an H2D over MQTT, the cause is the
tray_id:-1 Bambuddy itself put in the payload. Sending three
otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
succeeds, and cali_idx:-1 is accepted either way, so only that one
field is at fault. The H2D ignores the value entirely; the X1C
validates it, complains, and applies the write anyway. BambuStudio
always sends a real tray_id and defaults it to 0 for a manually
entered profile.

With tray_id:0 the acknowledgement is honest, and the printer echoes
back the sequence_id we sent -- confirmed for extrusion_cali_get,
_set and _del on both printer classes -- so it can be matched to the
write that caused it. Writes now return their sequence_id and the
routes await the verdict, turning a real failure into an error that
carries the printer's own reason. A printer that stays silent is
still treated as success: no answer is not evidence of refusal, and
firmware that never answers must not turn every save into an error.

Raises the ack to INFO. It sat at DEBUG, so the one line that
explains a failed save was absent from every support bundle -- the
same reasoning that put ams_filament_drying at INFO for #1447.

Also fixes extrusion_cali_set building its payload from
str(self._sequence_id) without incrementing first, reusing the
previous command's id. Harmless while nothing correlated on it,
fatal now that the write path does.

Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
which the K-Profiles UI previously showed as "Not reported by
printer" -- not a value anyone can save. Most printers omit the
nozzle identity from their calibration table entirely, and the slicer
treats that as Standard rather than unknown; Bambuddy now does the
same and keeps the choice editable. The field is hidden only where
the model ships a single nozzle variant, using the slicer's own rule
(len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
preset) evaluated across every bundled Bambu profile. That puts only
A1, A1 Mini and A2L on the hidden side -- it is not the single-
versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
are all single-nozzle and all carry two variants. Editing a profile
also no longer writes back an empty nozzle_id.

Wiki records that on printers which omit the field the chosen flow
type is discarded by the firmware and reads back as Standard, in
Bambu Studio as well, so it does not get filed as a bug again.
2026-08-01 10:35:22 +02:00

307 lines
12 KiB
Python

"""Unit tests for printer model utilities."""
import pytest
from backend.app.services.camera import get_camera_port, supports_rtsp
from backend.app.utils.printer_models import (
CARBON_ROD_MODELS,
LINEAR_RAIL_MODELS,
STEEL_ROD_MODELS,
get_rod_type,
has_ethernet,
has_external_storage,
has_remote_storage_toggle,
is_dual_nozzle_model,
normalize_printer_model,
normalize_printer_model_id,
supports_nozzle_flow_type,
)
class TestGetRodType:
"""Tests for get_rod_type() rod/rail classification."""
@pytest.mark.parametrize("model", ["X1C", "X1", "X1E", "P1P", "P1S"])
def test_carbon_rod_models(self, model: str):
assert get_rod_type(model) == "carbon"
@pytest.mark.parametrize("model", ["C11", "C12", "C13"])
def test_carbon_rod_internal_codes(self, model: str):
assert get_rod_type(model) == "carbon"
def test_p2s_is_steel_rod(self):
"""P2S uses hardened steel rods, not carbon rods (#640)."""
assert get_rod_type("P2S") == "steel_rod"
def test_p2s_internal_code_is_steel_rod(self):
"""N7 (P2S internal code) uses steel rods."""
assert get_rod_type("N7") == "steel_rod"
@pytest.mark.parametrize("model", ["A1", "A1 Mini", "H2D", "H2D Pro", "H2C", "H2S"])
def test_linear_rail_models(self, model: str):
assert get_rod_type(model) == "linear_rail"
@pytest.mark.parametrize("model", ["N1", "N2S", "A11", "A12", "O1D", "O1E", "O2D", "O1C", "O1C2", "O1S"])
def test_linear_rail_internal_codes(self, model: str):
assert get_rod_type(model) == "linear_rail"
def test_unknown_model_returns_none(self):
assert get_rod_type("UNKNOWN") is None
def test_none_returns_none(self):
assert get_rod_type(None) is None
def test_case_insensitive(self):
assert get_rod_type("p2s") == "steel_rod"
assert get_rod_type("x1c") == "carbon"
assert get_rod_type("a1") == "linear_rail"
def test_strips_whitespace_and_dashes(self):
assert get_rod_type(" P2S ") == "steel_rod"
assert get_rod_type("A1-Mini") == "linear_rail"
class TestX2DModel:
"""X2D printer support (issue #988).
The X2D is a dual-nozzle enclosed printer launched April 2026. It shares
the hardened steel rod hardware with P2S (NOT carbon rods) and uses
RTSP on port 322 like other X/H series printers. Internal SSDP/MQTT
model code is "N6"; serial numbers begin with "20P9".
"""
def test_x2d_is_steel_rod_display_name(self):
assert get_rod_type("X2D") == "steel_rod"
def test_x2d_is_steel_rod_internal_code(self):
assert get_rod_type("N6") == "steel_rod"
def test_x2d_model_id_map(self):
assert normalize_printer_model_id("N6") == "X2D"
def test_x2d_model_map(self):
assert normalize_printer_model("Bambu Lab X2D") == "X2D"
def test_x2d_has_ethernet_display_name(self):
assert has_ethernet("X2D") is True
def test_x2d_has_ethernet_internal_code(self):
assert has_ethernet("N6") is True
def test_x2d_supports_rtsp_display_name(self):
assert supports_rtsp("X2D") is True
def test_x2d_supports_rtsp_internal_code(self):
assert supports_rtsp("N6") is True
def test_x2d_camera_port_is_rtsp(self):
assert get_camera_port("N6") == 322
assert get_camera_port("X2D") == 322
def test_x2d_not_in_carbon_rod_set(self):
"""Regression guard: X2D has hardened steel rods, not carbon (#988).
A prior PR classified X2D as carbon; the reporter confirmed it uses
the same stainless steel rod gantry as P2S. This assertion pins the
classification so a future change that reverts it will fail loudly.
"""
assert "X2D" not in CARBON_ROD_MODELS
assert "N6" not in CARBON_ROD_MODELS
assert "X2D" in STEEL_ROD_MODELS
assert "N6" in STEEL_ROD_MODELS
class TestA2LModel:
"""A2L printer support (#1684).
The A2L is a hybrid 3D printer + cutter/plotter announced June 2026. It
uses linear rails like the A1 family, has NO Ethernet (Wi-Fi 2.4 GHz only),
a low-rate chamber-image camera on port 6000 (no RTSP), and a single FDM
extruder (the second "tool head" in BambuStudio's profile is the cutter,
not a second extruder — must NOT be classified as dual-nozzle). Internal
SSDP/MQTT model code is "N9"; serial numbers begin with "26A19".
"""
def test_a2l_is_linear_rail_display_name(self):
assert get_rod_type("A2L") == "linear_rail"
def test_a2l_is_linear_rail_internal_code(self):
assert get_rod_type("N9") == "linear_rail"
def test_a2l_model_id_map(self):
assert normalize_printer_model_id("N9") == "A2L"
def test_a2l_model_map(self):
assert normalize_printer_model("Bambu Lab A2L") == "A2L"
def test_a2l_has_no_ethernet_display_name(self):
"""A2L specs (bambulab.com/de-de/a2l/specs) list Ethernet 'Nicht verfügbar'."""
assert has_ethernet("A2L") is False
def test_a2l_has_no_ethernet_internal_code(self):
assert has_ethernet("N9") is False
def test_a2l_does_not_support_rtsp_display_name(self):
"""A2L uses the low-rate chamber-image protocol on port 6000, not RTSP."""
assert supports_rtsp("A2L") is False
def test_a2l_does_not_support_rtsp_internal_code(self):
assert supports_rtsp("N9") is False
def test_a2l_camera_port_is_chamber_image(self):
assert get_camera_port("A2L") == 6000
assert get_camera_port("N9") == 6000
def test_a2l_is_not_dual_nozzle(self):
"""A2L has a single FDM extruder + a cutter/plotter head. The
BambuStudio profile flag ``use_double_extruder_default_texture`` flags
the dual TOOL HEADS, not dual filament extrusion — A2L must not land
in the dual-nozzle group or AMS routing will target the deputy slot
and the firmware will reject the print with 07FF_8012.
"""
assert is_dual_nozzle_model("A2L") is False
assert is_dual_nozzle_model("N9") is False
def test_a2l_in_linear_rail_set(self):
assert "A2L" in LINEAR_RAIL_MODELS
assert "N9" in LINEAR_RAIL_MODELS
def test_a2l_not_in_carbon_or_steel_rod_sets(self):
assert "A2L" not in CARBON_ROD_MODELS
assert "N9" not in CARBON_ROD_MODELS
assert "A2L" not in STEEL_ROD_MODELS
assert "N9" not in STEEL_ROD_MODELS
class TestA1SeriesModelIds:
"""Regression guard for the A1-family internal-code → display-name map.
The serial-prefix and firmware-API key tables across the codebase agree
that N2S is the A1 (serial prefix 039) and N1 is the A1 Mini (serial
prefix 030). PRINTER_MODEL_ID_MAP had these swapped, which silently
misclassified A1 as A1 Mini in any path that resolved by internal code.
"""
def test_n2s_is_a1(self):
assert normalize_printer_model_id("N2S") == "A1"
def test_n1_is_a1_mini(self):
assert normalize_printer_model_id("N1") == "A1 Mini"
class TestDualNozzleModel:
"""is_dual_nozzle_model — the single source of truth for nozzle class,
consumed by start_print, the K-profile routes, and the re-slice guard."""
def test_h2d_and_pro_are_dual(self):
# Takes a normalized model code (like has_ethernet) — "H2D Pro" with a
# space is accepted; full "Bambu Lab …" names are normalized by callers.
assert is_dual_nozzle_model("H2D") is True
assert is_dual_nozzle_model("H2D Pro") is True
assert is_dual_nozzle_model("H2DPRO") is True
def test_internal_codes_are_dual(self):
assert is_dual_nozzle_model("O1D") is True # H2D
assert is_dual_nozzle_model("O1E") is True # H2D Pro
def test_single_nozzle_models_are_not_dual(self):
# H2S is in the H2 family but single-nozzle (#1386) — must be False.
for model in ("X1C", "X1E", "P1S", "P1P", "A1", "A1 Mini", "P2S", "H2S"):
assert is_dual_nozzle_model(model) is False, model
def test_none_and_empty_are_not_dual(self):
assert is_dual_nozzle_model(None) is False
assert is_dual_nozzle_model("") is False
class TestSupportsNozzleFlowType:
"""Which models offer a Standard / High Flow choice on a K-profile.
Mirrors the slicer's own rule — BambuStudio/OrcaSlicer gate their
Nozzle-Flow control on ``len(nozzle_volume) // len(nozzle_diameter) > 1``
read from the machine preset. Evaluated over every bundled Bambu profile,
only the A-series lands on one variant. Getting this wrong in the
permissive direction shows a redundant dropdown; getting it wrong in the
other direction makes half a printer's calibration table unreachable.
"""
def test_a_series_has_one_flow_variant(self):
for model in ("A1", "A1 Mini", "A1MINI", "A2L"):
assert supports_nozzle_flow_type(model) is False, model
def test_a_series_internal_codes(self):
for code in ("N1", "N2S", "N9", "A04", "A11", "A12"):
assert supports_nozzle_flow_type(code) is False, code
def test_single_nozzle_models_still_offer_both_flows(self):
# The split is NOT nozzle count: all of these are single-nozzle and
# all carry two nozzle_volume variants in their machine preset.
for model in ("X1", "X1C", "X1E", "P1P", "P1S", "P2S", "H2S"):
assert supports_nozzle_flow_type(model) is True, model
def test_dual_nozzle_models_offer_both_flows(self):
for model in ("H2D", "H2D Pro", "H2C"):
assert supports_nozzle_flow_type(model) is True, model
def test_unknown_and_empty_default_to_supported(self):
# Fail open: a redundant dropdown beats an unreachable half-table.
assert supports_nozzle_flow_type(None) is True
assert supports_nozzle_flow_type("") is True
assert supports_nozzle_flow_type("SomeFuturePrinter") is True
class TestHasExternalStorage:
"""Pins which Bambu models have a MicroSD slot. The connection
diagnostic flips its ``external_storage`` check from ``fail`` to
``skip`` based on this — a false add (X1C marked as no-storage) would
silently disable a genuine fail signal for X1/P1/P2S/H2 users."""
@pytest.mark.parametrize("model", ["A1", "A1 Mini", "A1MINI", "A1-Mini", "a1"])
def test_a1_series_has_no_external_storage(self, model: str):
assert has_external_storage(model) is False
@pytest.mark.parametrize("model", ["N1", "N2S", "A04", "A11", "A12"])
def test_a1_internal_codes_have_no_external_storage(self, model: str):
assert has_external_storage(model) is False
@pytest.mark.parametrize(
"model",
["X1C", "X1E", "X1", "P1S", "P1P", "P2S", "H2D", "H2D Pro", "H2C", "H2S", "X2D"],
)
def test_other_models_have_external_storage(self, model: str):
assert has_external_storage(model) is True
def test_unknown_model_defaults_to_true(self):
# Default-true keeps the diagnostic active for new Bambu models;
# add them to NO_EXTERNAL_STORAGE_MODELS explicitly when they ship
# without a slot.
assert has_external_storage("BrandNewModel2027") is True
def test_none_and_empty_default_to_true(self):
assert has_external_storage(None) is True
assert has_external_storage("") is True
class TestHasRemoteStorageToggle:
"""#2524: P1-series have a slot but no reachable control to enable the
"Store sent files on external storage" option. The diagnostic uses this
to skip (not fail) on those models. A false add here would silently
disable the genuine fail signal for X1/P2S/H2 users."""
@pytest.mark.parametrize("model", ["P1S", "P1P", "p1s", "P1-S", "P1 S"])
def test_p1_series_has_no_reachable_toggle(self, model: str):
assert has_remote_storage_toggle(model) is False
@pytest.mark.parametrize(
"model",
["X1C", "X1E", "X1", "P2S", "H2D", "H2D Pro", "H2C", "H2S", "X2D", "A1", "A1 Mini"],
)
def test_other_models_have_reachable_toggle(self, model: str):
assert has_remote_storage_toggle(model) is True
def test_unknown_and_empty_default_to_true(self):
# Default-true keeps the fail signal active for models not explicitly
# listed — the skip only applies to known no-toggle firmware.
assert has_remote_storage_toggle("BrandNewModel2027") is True
assert has_remote_storage_toggle(None) is True
assert has_remote_storage_toggle("") is True