Просмотр исходного кода

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.
maziggy 1 месяц назад
Родитель
Сommit
c765d2f2fb

Разница между файлами не показана из-за своего большого размера
+ 2 - 0
CHANGELOG.md


+ 18 - 0
backend/app/api/routes/kprofiles.py

@@ -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 - 0
backend/app/schemas/printer.py

@@ -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
backend/app/services/bambu_mqtt.py

@@ -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 - 0
backend/app/utils/printer_models.py

@@ -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.
 

+ 124 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -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 - 0
backend/tests/unit/test_printer_models.py

@@ -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

+ 4 - 0
frontend/src/api/client.ts

@@ -357,6 +357,10 @@ export interface Printer {
   model: string | null;
   location: string | null;  // Group/location name
   nozzle_count: number;  // 1 or 2, auto-detected from MQTT
+  // Model is sold with both Standard and High Flow nozzles, so a K-profile's
+  // flow type is a real choice. Derived from the model, not the nozzle count —
+  // only the A-series has a single variant.
+  supports_nozzle_flow_type: boolean;
   is_active: boolean;
   auto_archive: boolean;
   external_camera_url: string | null;

+ 58 - 53
frontend/src/components/KProfilesView.tsx

@@ -49,23 +49,26 @@ const truncateK = (value: string) => {
   return (Math.trunc(num * 1000) / 1000).toFixed(3);
 };
 
-// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S").
-// Single-nozzle printers omit nozzle_id from their extrusion_cali_get response
-// entirely (#1748), and there is no other field to recover the flow type from —
-// so return '' and let the caller show nothing rather than assert "Standard".
-const getFlowTypeLabel = (nozzleId: string) => {
-  if (nozzleId.startsWith('HH')) return 'HF';  // High Flow
-  if (nozzleId.startsWith('HS')) return 'S';   // Standard Flow
-  return '';  // not reported by the printer
-};
-
-// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00").
-// '' when the printer reported no nozzle_id — see getFlowTypeLabel.
+// nozzle_id encodes the flow type, per the slicer's own generator:
+//   "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
+// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
+// not a material code.
+const STANDARD_FLOW = 'HS00';
+const HIGH_FLOW = 'HH00';
+
+// Many printers omit nozzle_id from their extrusion_cali_get response entirely
+// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
+// Standard (its parser falls back to nvtStandard when the key is absent), and
+// so do we: the flow type stays a real, editable value rather than a blank.
 const getNozzleTypePrefix = (nozzleId: string) => {
   const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
-  return match ? match[1] : '';
+  return match ? match[1] : STANDARD_FLOW;
 };
 
+// Short label for the profile list.
+const getFlowTypeLabel = (nozzleId: string) =>
+  getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
+
 // Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
 const extractFilamentName = (profileName: string) => {
   // Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
@@ -132,7 +135,7 @@ function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onTo
             </span>
           )}
           <span className="text-xs text-bambu-gray whitespace-nowrap">
-            {[flowType, diameter].filter(Boolean).join(' ')}
+            {flowType} {diameter}
           </span>
         </div>
         {note && (
@@ -165,6 +168,7 @@ interface KProfileModalProps {
   builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup
   filamentPresets?: FilamentPresetOption[];  // Every filament this install knows, tiered
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
+  supportsFlowType?: boolean;  // Model sells both Standard and High Flow nozzles
   initialNote?: string;  // Initial note value for the profile
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
   onClose: () => void;
@@ -181,6 +185,7 @@ function KProfileModal({
   builtinFilaments = [],
   filamentPresets = [],
   isDualNozzle = false,
+  supportsFlowType = true,
   initialNote = '',
   initialNoteKey = null,
   onClose,
@@ -207,7 +212,7 @@ function KProfileModal({
   // single-nozzle models never do (#1748) — showing "High Flow" there was the
   // UI inventing a value the printer never sent.
   const [nozzleType, setNozzleType] = useState(
-    profile ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
+    profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
   );
   const [modalDiameter, setModalDiameter] = useState(
     profile?.nozzle_diameter || nozzleDiameter
@@ -351,11 +356,14 @@ function KProfileModal({
     const nozzleId = `${nozzleType}-${modalDiameter}`;
 
     // An edit is delete + re-add on single-nozzle printers, so the nozzle
-    // fields have to survive the round trip untouched — both selects are
-    // disabled while editing. Rebuilding them from the selects is what let a
-    // 0.6mm profile come back as "HH00-0.4" once the parse defaults had
-    // stamped it 0.4 (#1748); pass through what the printer reported instead.
-    const editNozzleId = profile ? profile.nozzle_id : nozzleId;
+    // fields have to survive the round trip — both selects are disabled while
+    // editing. Rebuilding them blindly from the selects is what let a 0.6mm
+    // profile come back as "HH00-0.4" once the parse defaults had stamped it
+    // 0.4 (#1748), so prefer whatever the printer reported. Where it reported
+    // no nozzle_id at all, send the rebuilt one rather than an empty string —
+    // the field is part of the profile's identity on the wire and the slicer
+    // always populates it.
+    const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
     const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
 
     // The printer indexes its calibration table by filament_id, so the preset
@@ -562,7 +570,7 @@ function KProfileModal({
                               // Auto-generate the profile name, but never over
                               // an entry the user typed.
                               if (!name) {
-                                const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
+                                const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
                                 setName(`${flowLabel} ${f.name}`);
                               }
                             }}
@@ -582,9 +590,12 @@ function KProfileModal({
               )}
             </div>
 
-            {/* Flow Type and Nozzle Size - read-only when editing */}
-            <div className="grid grid-cols-2 gap-4">
-              <div>
+            {/* Flow Type and Nozzle Size - read-only when editing. Flow type
+                is hidden on models sold with a single nozzle variant (the
+                A-series), where the choice would be meaningless — same gate
+                the slicer applies via support_nozzle_volume(). */}
+            <div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
+              <div className={supportsFlowType ? '' : 'hidden'}>
                 <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
                 <select
                   value={nozzleType}
@@ -596,7 +607,7 @@ function KProfileModal({
                     if (!profile && filamentChoice && !name) {
                       const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
                       if (selectedFilament) {
-                        const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
+                        const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
                         setName(`${flowLabel} ${selectedFilament.name}`);
                       }
                     }
@@ -604,14 +615,8 @@ function KProfileModal({
                   disabled={!!profile}
                   className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 >
-                  {/* Only reachable when editing a profile the printer
-                      reported without a nozzle_id — the select is disabled
-                      there, so this is a readout, not a choice. */}
-                  {nozzleType === '' && (
-                    <option value="">{t('kProfiles.modal.flowTypeNotReported')}</option>
-                  )}
-                  <option value="HH00">{t('kProfiles.modal.highFlow')}</option>
-                  <option value="HS00">{t('kProfiles.modal.standard')}</option>
+                  <option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
+                  <option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
                 </select>
               </div>
               <div>
@@ -993,21 +998,6 @@ export function KProfilesView() {
     return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
   }, [builtinFilamentMap]);
 
-  // Whether the printer reports a nozzle_id at all. Single-nozzle models omit
-  // it from every extrusion_cali_get entry (#1748), so a flow-type filter there
-  // could only ever match nothing — hide it instead of offering a control that
-  // silently empties the list.
-  const hasFlowTypeInfo = React.useMemo(
-    () => (kprofiles?.profiles ?? []).some((p) => getFlowTypeLabel(p.nozzle_id) !== ''),
-    [kprofiles?.profiles]
-  );
-
-  // Don't strand the list behind a filter whose control just disappeared
-  // (switching printers, or a refetch that no longer carries nozzle ids).
-  useEffect(() => {
-    if (!hasFlowTypeInfo) setFlowTypeFilter('all');
-  }, [hasFlowTypeInfo]);
-
   // Filter and sort profiles
   // Note: nozzle diameter filtering is done server-side via MQTT request
   const filteredProfiles = React.useMemo(() => {
@@ -1054,6 +1044,18 @@ export function KProfilesView() {
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
 
+  // Whether this printer model is sold with both Standard and High Flow
+  // nozzles. Comes from the model, not from whether the payload happened to
+  // carry a nozzle_id — most printers omit that field entirely (#1748) while
+  // still offering both flows. Only the A-series has a single variant.
+  const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
+
+  // Don't strand the list behind a filter whose control just disappeared.
+  useEffect(() => {
+    if (!supportsFlowType) setFlowTypeFilter('all');
+  }, [supportsFlowType]);
+
+
   // Keyboard shortcuts
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1145,10 +1147,10 @@ export function KProfilesView() {
               name: p.name,
               k_value: parseFloat(p.k_value).toFixed(6),
               filament_id: p.filament_id,
-              // Keep an absent nozzle_id absent. Exports from single-nozzle
-              // printers carry none (#1748), and HH00 vs HS00 is a coin flip
-              // we'd be writing to the printer as if it were fact.
-              nozzle_id: p.nozzle_id || '',
+              // An export from a printer that reports no nozzle_id carries
+              // none; fall back to Standard, the same default the slicer's
+              // parser uses for a missing field.
+              nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
               nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
               extruder_id: p.extruder_id ?? 0,
               slot_id: 0, // Always create new
@@ -1394,7 +1396,7 @@ export function KProfilesView() {
             </select>
           </div>
         )}
-        {hasFlowTypeInfo && (
+        {supportsFlowType && (
           <div className="w-32">
             <select
               value={flowTypeFilter}
@@ -1603,6 +1605,7 @@ export function KProfilesView() {
             builtinFilaments={enrichedBuiltinFilaments}
             filamentPresets={filamentPresets}
             isDualNozzle={isDualNozzle}
+            supportsFlowType={supportsFlowType}
             initialNote={note}
             initialNoteKey={key}
             onSaveNote={handleSaveNote}
@@ -1629,6 +1632,7 @@ export function KProfilesView() {
           builtinFilaments={enrichedBuiltinFilaments}
           filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
+          supportsFlowType={supportsFlowType}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
           onClose={() => {
@@ -1651,6 +1655,7 @@ export function KProfilesView() {
           builtinFilaments={enrichedBuiltinFilaments}
           filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
+          supportsFlowType={supportsFlowType}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
           // Pass profile data but without slot_id to create a new profile

+ 0 - 1
frontend/src/i18n/locales/de.ts

@@ -5046,7 +5046,6 @@ export default {
       flowType: 'Flusstyp',
       highFlow: 'Hoher Durchfluss',
       standard: 'Standard',
-      flowTypeNotReported: 'Vom Drucker nicht gemeldet',
       nozzleSize: 'Düsengröße',
       extruder: 'Extruder',
       extruders: 'Extruder',

+ 0 - 1
frontend/src/i18n/locales/en.ts

@@ -5090,7 +5090,6 @@ export default {
       flowType: 'Flow Type',
       highFlow: 'High Flow',
       standard: 'Standard',
-      flowTypeNotReported: 'Not reported by printer',
       nozzleSize: 'Nozzle Size',
       extruder: 'Extruder',
       extruders: 'Extruders',

+ 0 - 1
frontend/src/i18n/locales/es.ts

@@ -5055,7 +5055,6 @@ export default {
       flowType: 'Tipo de flujo',
       highFlow: 'Flujo alto',
       standard: 'Estándar',
-      flowTypeNotReported: 'No informado por la impresora',
       nozzleSize: 'Tamaño de la boquilla',
       extruder: 'Extrusor',
       extruders: 'Extrusores',

+ 0 - 1
frontend/src/i18n/locales/fr.ts

@@ -5036,7 +5036,6 @@ export default {
       flowType: 'Type de débit',
       highFlow: 'Haut Débit (HF)',
       standard: 'Standard',
-      flowTypeNotReported: 'Non communiqué par l\'imprimante',
       nozzleSize: 'Taille buse',
       extruder: 'Extrudeur',
       extruders: 'Extrudeurs',

+ 0 - 1
frontend/src/i18n/locales/it.ts

@@ -5035,7 +5035,6 @@ export default {
       flowType: 'Tipo flow',
       highFlow: 'Alto flusso',
       standard: 'Standard',
-      flowTypeNotReported: 'Non riportato dalla stampante',
       nozzleSize: 'Dimensione ugello',
       extruder: 'Estrusore',
       extruders: 'Estrusori',

+ 0 - 1
frontend/src/i18n/locales/ja.ts

@@ -5047,7 +5047,6 @@ export default {
       flowType: 'フロータイプ',
       highFlow: 'ハイフロー',
       standard: 'スタンダード',
-      flowTypeNotReported: 'プリンターから報告なし',
       nozzleSize: 'ノズルサイズ',
       extruder: 'エクストルーダー',
       extruders: 'エクストルーダー',

+ 0 - 1
frontend/src/i18n/locales/ko.ts

@@ -4790,7 +4790,6 @@ export default {
       flowType: '유량 유형',
       highFlow: '고유량',
       standard: '표준',
-      flowTypeNotReported: '프린터에서 보고하지 않음',
       nozzleSize: '노즐 크기',
       extruder: '압출기',
       extruders: '압출기',

+ 0 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -5035,7 +5035,6 @@ export default {
       flowType: 'Tipo de Fluxo',
       highFlow: 'Alto Fluxo',
       standard: 'Padrão',
-      flowTypeNotReported: 'Não informado pela impressora',
       nozzleSize: 'Tamanho do Bico',
       extruder: 'Extrusor',
       extruders: 'Extrusores',

+ 0 - 1
frontend/src/i18n/locales/ru.ts

@@ -4778,7 +4778,6 @@ export default {
       flowType: "Тип потока",
       highFlow: "Высокопоточный",
       standard: "Стандартный",
-      flowTypeNotReported: "Принтер не сообщает",
       nozzleSize: "Диаметр сопла",
       extruder: "Экструдер",
       extruders: "Экструдеры",

+ 0 - 1
frontend/src/i18n/locales/tr.ts

@@ -5015,7 +5015,6 @@ export default {
       flowType: 'Akış Türü',
       highFlow: 'Yüksek Akış',
       standard: 'Standart',
-      flowTypeNotReported: 'Yazıcı tarafından bildirilmedi',
       nozzleSize: 'Nozul Boyutu',
       extruder: 'Ekstrüder',
       extruders: 'Ekstrüderler',

+ 0 - 1
frontend/src/i18n/locales/uk.ts

@@ -5090,7 +5090,6 @@ export default {
       flowType: "Тип потоку",
       highFlow: "Сопло з високим потоком",
       standard: "Стандартний",
-      flowTypeNotReported: "Принтер не повідомляє",
       nozzleSize: "Розмір сопла",
       extruder: "Екструдер",
       extruders: "Екструдери",

+ 0 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -5035,7 +5035,6 @@ export default {
       flowType: '流量类型',
       highFlow: '高流量',
       standard: '标准',
-      flowTypeNotReported: '打印机未报告',
       nozzleSize: '喷嘴尺寸',
       extruder: '挤出机',
       extruders: '挤出机',

+ 0 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -5035,7 +5035,6 @@ export default {
       flowType: '流量類型',
       highFlow: '高流量',
       standard: '標準',
-      flowTypeNotReported: '印表機未回報',
       nozzleSize: '噴嘴尺寸',
       extruder: '擠出機',
       extruders: '擠出機',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D27DV9N0.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-E-CRp_kM.js"></script>
+    <script type="module" crossorigin src="/assets/index-D27DV9N0.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов