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.
This commit is contained in:
maziggy
2026-08-02 09:53:51 +02:00
24 changed files with 539 additions and 218 deletions
+18
View File
@@ -148,6 +148,9 @@ async def set_kprofile(
)
if not delete_success:
raise HTTPException(500, "Failed to delete existing K-profile for edit")
ok, detail = await client.await_cali_ack(delete_success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
# Wait for printer to process the delete before adding
await asyncio.sleep(0.5)
@@ -179,6 +182,13 @@ async def set_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile command")
# The printer answers extrusion_cali_set with result/reason, echoing our
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
# so a rejected write was reported to the user as saved.
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
return {"success": True, "message": message}
@@ -239,6 +249,10 @@ async def set_kprofiles_batch(
if not success:
raise HTTPException(500, "Failed to send K-profiles batch command")
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
@@ -283,6 +297,10 @@ async def delete_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile delete command")
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the delete: {detail}")
# Wait for printer to process the delete before frontend refetches
await asyncio.sleep(0.5)
+8
View File
@@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from backend.app.utils.printer_models import supports_nozzle_flow_type
class PrinterBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
@@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
# Whether the model is sold with both Standard and High Flow nozzles, so a
# K-profile's flow type is a real choice rather than a meaningless field.
# Derived from the model, not from nozzle_count — see
# printer_models.supports_nozzle_flow_type.
supports_nozzle_flow_type: bool = True
print_hours_offset: float = 0.0
external_camera_url: str | None = None
external_camera_type: str | None = None
@@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
"camera_rotation": printer.camera_rotation,
"is_active": printer.is_active,
"nozzle_count": printer.nozzle_count,
"supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
"print_hours_offset": printer.print_hours_offset,
"plate_detection_enabled": printer.plate_detection_enabled,
"created_at": printer.created_at,
+110 -23
View File
@@ -818,6 +818,11 @@ class BambuMQTTClient:
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
self._sequence_id: int = 0
self._pending_kprofile_requests: dict[str, dict] = {}
# Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
# keyed by the sequence_id we sent. The printer echoes it back, measured
# on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
# by await_cali_ack.
self._pending_cali_acks: dict[str, dict | None] = {}
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
# Key: module_name, Value: timestamp when command was sent
@@ -1618,7 +1623,24 @@ class BambuMQTTClient:
if "command" in print_data:
cmd = print_data.get("command")
logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
if cmd in ("extrusion_cali_set", "extrusion_cali_del"):
# INFO, not debug: this is the printer's verdict on a write
# the user just made, and it was invisible in support
# bundles for as long as it sat at DEBUG (#2718). Same
# reasoning as ams_filament_drying below.
logger.info(
"[%s] %s response: result=%s reason=%s seq=%s",
self.serial_number,
cmd,
print_data.get("result"),
print_data.get("reason", ""),
print_data.get("sequence_id"),
)
logger.debug("[%s] %s full response: %s", self.serial_number, cmd, print_data)
ack_seq = str(print_data.get("sequence_id", ""))
if ack_seq in self._pending_cali_acks:
self._pending_cali_acks[ack_seq] = print_data
elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
# AMS drying responses are rare (user-initiated only) and the
# full payload — including `result` and any `reason` code —
@@ -5602,6 +5624,51 @@ class BambuMQTTClient:
logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
return []
def _publish_cali_write(self, command: dict, seq_id: str) -> bool:
"""Publish a K-profile write and arm its ack slot.
Registration happens before the publish because the printer answers in
well under a second — measured at 70-150ms — which is comfortably
before an async caller gets back to awaiting.
"""
self._pending_cali_acks[seq_id] = None
try:
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
except Exception:
self._pending_cali_acks.pop(seq_id, None)
raise
return True
async def await_cali_ack(self, seq_id: str, timeout: float = 6.0) -> tuple[bool, str]:
"""Wait for the printer's verdict on a K-profile write.
Returns ``(ok, detail)``. ``ok`` is False only when the printer
explicitly said ``result: "fail"`` — a timeout returns True with a
detail string, because "no answer" is not evidence of rejection and
older firmware may not answer at all. Callers that need certainty read
the calibration table back.
Polled rather than event-driven on purpose: the ack is filled in by the
MQTT callback thread, and polling a dict costs one lookup every 50ms
for at most a few hundred milliseconds, against the cross-thread
event plumbing it would otherwise take.
"""
deadline = time.monotonic() + timeout
try:
while time.monotonic() < deadline:
ack = self._pending_cali_acks.get(seq_id)
if ack is not None:
result = str(ack.get("result", "")).lower()
reason = str(ack.get("reason", "") or "")
if result == "fail":
return (False, reason or "printer reported failure")
return (True, reason)
await asyncio.sleep(0.05)
finally:
self._pending_cali_acks.pop(seq_id, None)
logger.warning("[%s] No ack for K-profile write seq=%s within %.1fs", self.serial_number, seq_id, timeout)
return (True, "no acknowledgement from printer")
def set_kprofile(
self,
filament_id: str,
@@ -5613,7 +5680,7 @@ class BambuMQTTClient:
setting_id: str | None = None,
slot_id: int = 0,
cali_idx: int | None = None,
) -> bool:
) -> str | None:
"""Set/update a K-profile on the printer.
Args:
@@ -5628,13 +5695,16 @@ class BambuMQTTClient:
cali_idx: For edits, the existing slot being edited (enables in-place edit)
Returns:
True if command was sent, False otherwise
The sequence_id the command was sent under, so the caller can
await the printer's verdict via await_cali_ack. None if the
command could not be sent.
"""
if not self._client or not self.state.connected:
logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
return False
return None
self._sequence_id += 1
seq_id = str(self._sequence_id)
# Build the filament entry - printer uses cali_idx for profile identification
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
@@ -5662,7 +5732,13 @@ class BambuMQTTClient:
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id if setting_id else "",
"tray_id": -1,
# 0, not -1. Single-nozzle firmware validates this field and
# answers `result: "fail", reason: "invalid tray_id"` to -1 — while
# applying the write anyway, so the rejection looked like noise.
# Measured on an X1C: flipping only this value turns the ack into
# `success` (#2718). BambuStudio always sends a real tray_id and
# defaults it to 0 for a manually entered profile.
"tray_id": 0,
}
command = {
@@ -5670,7 +5746,7 @@ class BambuMQTTClient:
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
}
}
@@ -5679,14 +5755,14 @@ class BambuMQTTClient:
f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
)
logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
self._publish_cali_write(command, seq_id)
return seq_id
def set_kprofiles_batch(
self,
profiles: list[dict],
nozzle_diameter: str = "0.4",
) -> bool:
) -> str | None:
"""Set multiple K-profiles in a single command (for dual-nozzle).
Args:
@@ -5695,15 +5771,17 @@ class BambuMQTTClient:
nozzle_diameter: Common nozzle diameter for all profiles
Returns:
True if command was sent, False otherwise
The sequence_id the command was sent under (see set_kprofile),
or None if it could not be sent.
"""
if not self._client or not self.state.connected:
logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
return False
return None
import random
self._sequence_id += 1
seq_id = str(self._sequence_id)
filament_entries = []
for p in profiles:
@@ -5731,7 +5809,9 @@ class BambuMQTTClient:
"nozzle_diameter": nozzle_diameter,
"nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
"setting_id": setting_id if setting_id else "",
"tray_id": -1,
# See set_kprofile: -1 is rejected as "invalid tray_id" by
# single-nozzle firmware even though the write lands (#2718).
"tray_id": 0,
}
)
@@ -5740,15 +5820,15 @@ class BambuMQTTClient:
"command": "extrusion_cali_set",
"filaments": filament_entries,
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
}
}
command_json = json.dumps(command)
logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
self._publish_cali_write(command, seq_id)
return seq_id
def delete_kprofile(
self,
@@ -5758,7 +5838,7 @@ class BambuMQTTClient:
nozzle_diameter: str = "0.4",
extruder_id: int = 0,
setting_id: str | None = None,
) -> bool:
) -> str | None:
"""Delete a K-profile from the printer.
Args:
@@ -5770,13 +5850,15 @@ class BambuMQTTClient:
setting_id: Unique setting identifier (for X1C series)
Returns:
True if command was sent, False otherwise
The sequence_id the command was sent under (see set_kprofile),
or None if it could not be sent.
"""
if not self._client or not self.state.connected:
logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
return False
return None
self._sequence_id += 1
seq_id = str(self._sequence_id)
# Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
# single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
@@ -5792,7 +5874,7 @@ class BambuMQTTClient:
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
"extruder_id": extruder_id,
"nozzle_id": nozzle_id,
"filament_id": filament_id,
@@ -5806,7 +5888,7 @@ class BambuMQTTClient:
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
"filament_id": filament_id,
"cali_idx": cali_idx,
"setting_id": setting_id if setting_id else "",
@@ -5821,9 +5903,9 @@ class BambuMQTTClient:
f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
)
logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
# Use QoS 1 for reliable delivery (at least once)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
# QoS 1 for reliable delivery (at least once)
self._publish_cali_write(command, seq_id)
return seq_id
# =========================================================================
# Printer Control Commands
@@ -6666,6 +6748,11 @@ class BambuMQTTClient:
logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
return False
# Was reusing the previous command's id — harmless while nothing
# correlated on it, but the printer echoes sequence_id back and the
# K-profile write path now matches acks by it (#2718).
self._sequence_id += 1
nozzle_id = f"HS00-{nozzle_diameter}"
# A2L AMS-Lite: a normalised global tray (24-27) must go out as the
+49
View File
@@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
)
# Models sold with a single nozzle flow variant, so a Standard / High Flow
# choice on a K-profile is meaningless there. Derived from the slicer's own
# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
# single-nozzle and all carry two variants. Only the A-series has one.
SINGLE_NOZZLE_FLOW_MODELS = frozenset(
[
# Display names (uppercase, no spaces)
"A1",
"A1MINI",
"A2L",
# Internal codes
"N1", # A1 Mini
"N2S", # A1
"N9", # A2L
"A04", # A1 Mini (alternate)
"A11", # A1
"A12", # A1 Mini
]
)
# Models without any external storage (MicroSD / SD card slot).
# The A1 and A1 Mini ship with internal storage only — there is no
# firmware-side "Store sent files on external storage" toggle and no
@@ -290,6 +312,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
return normalized in DUAL_NOZZLE_MODELS
def supports_nozzle_flow_type(model: str | None) -> bool:
"""Return True if the model offers a Standard / High Flow nozzle choice.
A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
(Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
profile's identity on any printer where both exist — and meaningless noise
on one where only a single variant is sold.
The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
from the machine preset — ``support_nozzle_volume()`` is
``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
Bambu profile evaluated against that formula puts only the A-series on the
"one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
upward at 2 or more per extruder).
Defaults to True for unknown models: offering the choice on a printer that
turns out to have one flow type costs the user a redundant dropdown, while
hiding it on one that has two makes half its calibration table
unreachable.
"""
if not model:
return True
normalized = model.strip().upper().replace(" ", "").replace("-", "")
return normalized not in SINGLE_NOZZLE_FLOW_MODELS
def get_rod_type(model: str | None) -> str | None:
"""Return the rod/rail type for a printer model.
@@ -6916,6 +6916,130 @@ class TestKProfileNozzleDiameterFromEnvelope:
return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
class TestKProfileWriteAcks:
"""#2718: K-profile writes were fire-and-forget.
``set_kprofiles_batch`` published and returned True immediately, and the
printer's ``extrusion_cali_set`` answer was logged at DEBUG and dropped, so
a rejected write was reported to the user as saved. Two facts measured on
real hardware shape the fix: the printer echoes our ``sequence_id`` back
(so the ack can be correlated), and it answers ``result: "fail",
reason: "invalid tray_id"`` to ``tray_id: -1`` on single-nozzle firmware
while applying the write anyway — flipping that field to 0 is what makes
``result`` trustworthy.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="X1CTEST",
access_code="12345678",
)
client.state.connected = True
client._client = MagicMock()
return client
@staticmethod
def _sent(client):
return json.loads(client._client.publish.call_args[0][1])["print"]
def test_set_sends_tray_id_zero(self, mqtt_client):
mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
def test_batch_sends_tray_id_zero(self, mqtt_client):
mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99", "name": "t", "k_value": "0.020000"}])
assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
def test_writers_return_their_sequence_id(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
assert seq == self._sent(mqtt_client)["sequence_id"]
assert seq in mqtt_client._pending_cali_acks
def test_writers_return_none_when_disconnected(self, mqtt_client):
mqtt_client.state.connected = False
assert mqtt_client.set_kprofile(filament_id="GFL99", name="t", k_value="0.02") is None
assert mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99"}]) is None
assert mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4") is None
def test_per_tray_extrusion_cali_set_advances_the_sequence_id(self, mqtt_client):
# It used to reuse the previous command's id, which would silently
# defeat the correlation the write path now depends on.
before = mqtt_client._sequence_id
mqtt_client.extrusion_cali_set(tray_id=0, k_value=0.02)
assert mqtt_client._sequence_id > before
assert self._sent(mqtt_client)["sequence_id"] == str(mqtt_client._sequence_id)
@pytest.mark.asyncio
async def test_failure_ack_is_reported_as_failure(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
mqtt_client._process_message(
{
"print": {
"command": "extrusion_cali_set",
"result": "fail",
"reason": "invalid tray_id",
"sequence_id": seq,
}
}
)
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=2.0)
assert ok is False
assert detail == "invalid tray_id"
@pytest.mark.asyncio
async def test_success_ack_passes(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
mqtt_client._process_message(
{"print": {"command": "extrusion_cali_set", "result": "success", "reason": "", "sequence_id": seq}}
)
ok, _ = await mqtt_client.await_cali_ack(seq, timeout=2.0)
assert ok is True
@pytest.mark.asyncio
async def test_ack_for_another_write_does_not_resolve_this_one(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
mqtt_client._process_message(
{
"print": {
"command": "extrusion_cali_set",
"result": "fail",
"reason": "invalid tray_id",
"sequence_id": "999999",
}
}
)
# Unrelated sequence_id: this write is still unanswered, so it times
# out rather than inheriting someone else's failure.
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
assert ok is True
assert "no acknowledgement" in detail
@pytest.mark.asyncio
async def test_silence_is_not_treated_as_rejection(self, mqtt_client):
# Firmware that never answers must not turn every save into an error.
seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
assert ok is True
assert "no acknowledgement" in detail
@pytest.mark.asyncio
async def test_pending_slot_is_released(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
await mqtt_client.await_cali_ack(seq, timeout=0.3)
assert mqtt_client._pending_cali_acks == {}
def test_delete_ack_is_matched_too(self, mqtt_client):
seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
mqtt_client._process_message(
{"print": {"command": "extrusion_cali_del", "result": "success", "sequence_id": seq}}
)
assert mqtt_client._pending_cali_acks[seq]["result"] == "success"
class TestKProfileRequestCorrelation:
"""#1748: K-profile requests timed out whenever two were in flight.
+37
View File
@@ -14,6 +14,7 @@ from backend.app.utils.printer_models import (
is_dual_nozzle_model,
normalize_printer_model,
normalize_printer_model_id,
supports_nozzle_flow_type,
)
@@ -213,6 +214,42 @@ class TestDualNozzleModel:
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