Browse Source

fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)

  H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
  and the reporter's diagnostic) but had been added to the H-family model
  gate in start_print_job. That single flag controlled both the firmware
  bool->int format (legitimately needed for the whole H-family, including
  H2S) and the dual-nozzle external-spool routing (correct only for actual
  dual-extruder printers).

  With no AMS attached and an external-spool slot (tray_id=254), the
  dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
  canonical 255 — exactly the failure the comment six lines above warns
  against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
  AMS mapping table". The use_ams=False fallback was also being skipped
  because the H-family bypass was meant for dual-nozzle routing.

  A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
  detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
  shares prefix "094" with H2D, so prefix detection misclassified it too.

  Split the conflated flag into two:

  - is_h_family — firmware format (int 0/1 for calibration fields).
    Includes H2S. H2S firmware structurally accepted the current command
    shape (failure was at AMS routing, not parsing), so the int format
    stays for H2S.

  - is_dual_nozzle — external-spool routing and use_ams gating. Excludes
    H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
    device.extruder.info, with a model-name fallback for the brief window
    after connect before push data arrives.

  The K-profile delete site and the kprofiles route now use the same
  runtime+model check instead of serial prefix.
maziggy 1 week ago
parent
commit
96fd4bb7e3

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 12 - 9
backend/app/api/routes/kprofiles.py

@@ -113,14 +113,17 @@ async def set_kprofile(
     if not client or not client.state.connected:
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
-    # Detect dual-nozzle families by serial number prefix.
-    # H2 series: legacy "094"; post-2026 H2C batches ship with "31B8B" (#1105).
-    # X2D series: "20P9".
-    is_h2d = printer.serial_number.startswith(("094", "20P9", "31B8B"))
-
-    if is_edit and is_h2d:
-        # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id
-        logger.info("[API] H2D in-place edit: cali_idx=%s", profile.slot_id)
+    # Detect dual-nozzle for the in-place edit format. Runtime detection from
+    # device.extruder.info beats serial-prefix heuristics — H2S shares prefix
+    # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
+    # for the brief window after connect before push data arrives.
+    is_dual_nozzle = client._is_dual_nozzle or (
+        printer.model and printer.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
+    )
+
+    if is_edit and is_dual_nozzle:
+        # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
+        logger.info("[API] Dual-nozzle in-place edit: cali_idx=%s", profile.slot_id)
         success = client.set_kprofile(
         success = client.set_kprofile(
             filament_id=profile.filament_id,
             filament_id=profile.filament_id,
             name=profile.name,
             name=profile.name,
@@ -133,7 +136,7 @@ async def set_kprofile(
             cali_idx=profile.slot_id,  # Pass the original slot for in-place edit
             cali_idx=profile.slot_id,  # Pass the original slot for in-place edit
         )
         )
     elif is_edit:
     elif is_edit:
-        # Non-H2D edit: use delete + add approach
+        # Single-nozzle edit: use delete + add approach
         logger.info("[API] Edit: deleting existing profile slot_id=%s", profile.slot_id)
         logger.info("[API] Edit: deleting existing profile slot_id=%s", profile.slot_id)
         delete_success = client.delete_kprofile(
         delete_success = client.delete_kprofile(
             cali_idx=profile.slot_id,
             cali_idx=profile.slot_id,

+ 46 - 20
backend/app/services/bambu_mqtt.py

@@ -3161,11 +3161,31 @@ class BambuMQTTClient:
         """
         """
         if self._client and self.state.connected:
         if self._client and self.state.connected:
             # Bambu print command format - matches Bambu Studio's format
             # Bambu print command format - matches Bambu Studio's format
-            # H2D series requires integer values (0/1) for calibration/leveling fields
-            # but use_ams MUST remain boolean — H2D Pro firmware interprets integer
-            # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
-            # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
-            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")
+            # H2-family firmware (H2D, H2D Pro, H2C, H2S, X2D) requires integer
+            # values (0/1) for calibration/leveling fields. X1C/P1S/A1/P2S need
+            # actual booleans. use_ams stays boolean across the board — H2D Pro
+            # firmware interprets integer use_ams as nozzle index (1 = deputy),
+            # causing wrong extruder routing (#1386 root cause was here too: the
+            # old flag conflated firmware-format with dual-nozzle routing).
+            is_h_family = self.model and self.model.upper().strip() in (
+                "H2D",
+                "H2D PRO",
+                "H2DPRO",
+                "H2C",
+                "H2S",
+                "X2D",
+            )
+            # Dual-nozzle routing for external spool (254 = deputy/left,
+            # 255 = main/right) and the use_ams=False fallback. H2S is in the
+            # H2 firmware family but is single-nozzle, despite sharing serial
+            # prefix "094" with H2D. Prefer runtime detection from
+            # device.extruder.info (set in _handle_push_status); fall back to
+            # model name for the brief window after connect before push data
+            # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
+            # as the primary signal.
+            is_dual_nozzle = self._is_dual_nozzle or (
+                self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
+            )
 
 
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
             ams_mapping2 = []
@@ -3194,7 +3214,7 @@ class BambuMQTTClient:
                         # to 07FF_8012 "Failed to get AMS mapping table" or stuck prints.
                         # to 07FF_8012 "Failed to get AMS mapping table" or stuck prints.
                         # Only H2D dual-nozzle printers use 254 (deputy/left nozzle).
                         # Only H2D dual-nozzle printers use 254 (deputy/left nozzle).
                         flat_ams_mapping.append(-1)
                         flat_ams_mapping.append(-1)
-                        ext_ams_id = tray_id if is_h2d else 255
+                        ext_ams_id = tray_id if is_dual_nozzle else 255
                         ams_mapping2.append({"ams_id": ext_ams_id, "slot_id": 0})
                         ams_mapping2.append({"ams_id": ext_ams_id, "slot_id": 0})
                     elif tray_id >= 128:
                     elif tray_id >= 128:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
@@ -3209,8 +3229,11 @@ class BambuMQTTClient:
 
 
             # If all mapped slots are external spool (no real AMS trays), force use_ams=False.
             # If all mapped slots are external spool (no real AMS trays), force use_ams=False.
             # P1S/P1P with no AMS rejects use_ams=True with "Failed to get AMS mapping table".
             # P1S/P1P with no AMS rejects use_ams=True with "Failed to get AMS mapping table".
-            # Skip for H2D series — use_ams controls nozzle routing on those printers.
-            if ams_mapping and use_ams and not is_h2d:
+            # Skip for dual-nozzle printers — use_ams controls nozzle routing there.
+            # H2S falls through this gate now (#1386): it is single-nozzle and was
+            # hitting the dual-nozzle bypass, which caused 07FF_8012 when printing
+            # without an AMS attached.
+            if ams_mapping and use_ams and not is_dual_nozzle:
                 if all(t is None or int(t) < 0 or int(t) >= 254 for t in ams_mapping):
                 if all(t is None or int(t) < 0 or int(t) >= 254 for t in ams_mapping):
                     use_ams = False
                     use_ams = False
                     logger.info(
                     logger.info(
@@ -3247,12 +3270,12 @@ class BambuMQTTClient:
                     "file": filename,
                     "file": filename,
                     "md5": "",
                     "md5": "",
                     "bed_type": "auto",
                     "bed_type": "auto",
-                    "timelapse": (1 if timelapse else 0) if is_h2d else timelapse,
-                    "bed_leveling": (1 if bed_levelling else 0) if is_h2d else bed_levelling,
+                    "timelapse": (1 if timelapse else 0) if is_h_family else timelapse,
+                    "bed_leveling": (1 if bed_levelling else 0) if is_h_family else bed_levelling,
                     "auto_bed_leveling": 1 if bed_levelling else 0,
                     "auto_bed_leveling": 1 if bed_levelling else 0,
-                    "flow_cali": (1 if flow_cali else 0) if is_h2d else flow_cali,
-                    "vibration_cali": (1 if vibration_cali else 0) if is_h2d else vibration_cali,
-                    "layer_inspect": (1 if layer_inspect else 0) if is_h2d else layer_inspect,
+                    "flow_cali": (1 if flow_cali else 0) if is_h_family else flow_cali,
+                    "vibration_cali": (1 if vibration_cali else 0) if is_h_family else vibration_cali,
+                    "layer_inspect": (1 if layer_inspect else 0) if is_h_family else layer_inspect,
                     "use_ams": use_ams,
                     "use_ams": use_ams,
                     "cfg": "0",
                     "cfg": "0",
                     "extrude_cali_flag": 0,
                     "extrude_cali_flag": 0,
@@ -3266,9 +3289,9 @@ class BambuMQTTClient:
                 }
                 }
             }
             }
 
 
-            if is_h2d:
+            if is_h_family:
                 logger.debug(
                 logger.debug(
-                    "[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)",
+                    "[%s] H-family firmware detected: using integer format for calibration fields (use_ams stays boolean)",
                     self.serial_number,
                     self.serial_number,
                 )
                 )
 
 
@@ -3980,11 +4003,14 @@ class BambuMQTTClient:
 
 
         self._sequence_id += 1
         self._sequence_id += 1
 
 
-        # Detect printer type by serial number prefix
-        # Dual-nozzle families:
-        #   H2 series: legacy "094"; post-2026 H2C batches ship with "31B8B" (#1105)
-        #   X2D series: "20P9"
-        is_dual_nozzle = self.serial_number.startswith(("094", "20P9", "31B8B"))
+        # 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.
+        # Prefer runtime detection from device.extruder.info; fall back to
+        # model name. H2S is single-nozzle but shares serial prefix "094" with
+        # H2D, so a prefix-only check misclassified it (#1386).
+        is_dual_nozzle = self._is_dual_nozzle or (
+            self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
+        )
 
 
         if is_dual_nozzle:
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter

+ 98 - 19
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3696,9 +3696,9 @@ class TestStartPrintAmsMapping:
         assert cmd["layer_inspect"] == 1
         assert cmd["layer_inspect"] == 1
 
 
     def test_p2s_still_uses_boolean_format(self, mqtt_client):
     def test_p2s_still_uses_boolean_format(self, mqtt_client):
-        """Regression guard: P2S is NOT in the is_h2d gate — must still use booleans.
+        """Regression guard: P2S is NOT in the H-family firmware gate — must still use booleans.
 
 
-        Adding X2D to the is_h2d set must not accidentally affect P2S, which
+        Adding X2D to the H-family set must not accidentally affect P2S, which
         is single-nozzle and uses boolean format like X1C/A1/P1.
         is single-nozzle and uses boolean format like X1C/A1/P1.
         """
         """
         mqtt_client.model = "P2S"
         mqtt_client.model = "P2S"
@@ -3708,6 +3708,58 @@ class TestStartPrintAmsMapping:
         assert cmd["timelapse"] is True
         assert cmd["timelapse"] is True
         assert cmd["flow_cali"] is False
         assert cmd["flow_cali"] is False
 
 
+    def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
+        """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
+
+        H2S shares serial prefix "094" and the H-family firmware-format
+        quirks with H2D, but it has a single extruder (nozzle_count=1
+        confirmed across 9+ support bundles). Routing the deputy-nozzle
+        sentinel (254) through to firmware on a single-nozzle printer
+        causes 07FF_8012 "Failed to get AMS mapping table" — exactly the
+        symptom reporter krootstijn hit when printing without an AMS.
+        """
+        mqtt_client.model = "H2S"
+        mqtt_client.start_print("test.3mf", ams_mapping=[254])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1]
+        assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
+
+    def test_h2s_no_ams_forces_use_ams_false(self, mqtt_client):
+        """H2S with only external spool must drop into the use_ams=False
+        fallback, like P1S/P1P. The dual-nozzle bypass kept this path
+        unreachable before #1386 — the firmware then rejected the print
+        with 07FF_8012 because there was no AMS mapping table.
+        """
+        mqtt_client.model = "H2S"
+        mqtt_client.start_print("test.3mf", ams_mapping=[254], use_ams=True)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["use_ams"] is False
+
+    def test_h2s_keeps_integer_format_for_calibration_fields(self, mqtt_client):
+        """H2S shares the H-family firmware (int 0/1 for calibration fields)
+        even though it's single-nozzle. Verified empirically against H2S
+        bundles: the print command structure was always accepted, only the
+        AMS routing failed (#1386).
+        """
+        mqtt_client.model = "H2S"
+        mqtt_client.start_print(
+            "test.3mf",
+            timelapse=True,
+            bed_levelling=False,
+            flow_cali=True,
+            vibration_cali=False,
+            layer_inspect=True,
+        )
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["timelapse"] == 1
+        assert cmd["bed_leveling"] == 0
+        assert cmd["flow_cali"] == 1
+        assert cmd["vibration_cali"] == 0
+        assert cmd["layer_inspect"] == 1
+
 
 
 class TestStartPrintUniqueIdentityFields:
 class TestStartPrintUniqueIdentityFields:
     """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
     """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
@@ -3818,15 +3870,16 @@ class TestStartPrintUniqueIdentityFields:
 
 
 
 
 class TestDeleteKProfileDualNozzleDetection:
 class TestDeleteKProfileDualNozzleDetection:
-    """Regression guard: dual-nozzle detection by serial prefix (#988).
+    """Regression guard: dual-nozzle detection for K-profile delete.
 
 
-    delete_kprofile branches on serial-prefix-derived dual-nozzle status.
-    H2D serials start with "094"; X2D serials start with "20P9". Non-dual
-    families (X1C "00M", P1S "01P", P2S "22E", A1 "039", etc.) must take
-    the single-nozzle branch.
+    delete_kprofile branches on dual-nozzle status to pick the wire format.
+    Source of truth is the runtime `_is_dual_nozzle` flag (set from
+    device.extruder.info); model name is the fallback used before push
+    data arrives. Serial-prefix detection alone is wrong — H2S shares
+    prefix "094" with H2D but is single-nozzle (#1386).
     """
     """
 
 
-    def _make_client(self, serial: str):
+    def _make_client(self, *, serial: str = "TEST", model: str | None = None, dual_runtime: bool = False):
         from unittest.mock import MagicMock
         from unittest.mock import MagicMock
 
 
         from backend.app.services.bambu_mqtt import BambuMQTTClient
         from backend.app.services.bambu_mqtt import BambuMQTTClient
@@ -3838,37 +3891,63 @@ class TestDeleteKProfileDualNozzleDetection:
         )
         )
         client._client = MagicMock()
         client._client = MagicMock()
         client.state.connected = True
         client.state.connected = True
+        client.model = model
+        client._is_dual_nozzle = dual_runtime
         return client
         return client
 
 
     def _published(self, client):
     def _published(self, client):
         return json.loads(client._client.publish.call_args[0][1])["print"]
         return json.loads(client._client.publish.call_args[0][1])["print"]
 
 
-    def test_h2d_serial_uses_dual_nozzle_format(self):
-        client = self._make_client("09400A000000001")
+    def test_h2d_model_uses_dual_nozzle_format(self):
+        client = self._make_client(serial="09400A000000001", model="H2D")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         cmd = self._published(client)
         cmd = self._published(client)
         # Dual-nozzle command omits setting_id.
         # Dual-nozzle command omits setting_id.
         assert "setting_id" not in cmd
         assert "setting_id" not in cmd
         assert cmd["extruder_id"] == 0
         assert cmd["extruder_id"] == 0
 
 
-    def test_x2d_serial_uses_dual_nozzle_format(self):
-        client = self._make_client("20P90A000000001")
+    def test_x2d_model_uses_dual_nozzle_format(self):
+        client = self._make_client(serial="20P90A000000001", model="X2D")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         cmd = self._published(client)
         cmd = self._published(client)
         assert "setting_id" not in cmd
         assert "setting_id" not in cmd
         assert cmd["extruder_id"] == 0
         assert cmd["extruder_id"] == 0
 
 
-    def test_h2c_new_prefix_uses_dual_nozzle_format(self):
-        """Post-2026 H2C batches ship with '31B8B' prefix instead of '094' (#1105)."""
-        client = self._make_client("31B8BP000000001")
+    def test_h2c_model_uses_dual_nozzle_format(self):
+        """Post-2026 H2C batches ship with '31B8B' prefix instead of '094' (#1105).
+        Model-name detection works regardless of serial prefix."""
+        client = self._make_client(serial="31B8BP000000001", model="H2C")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         cmd = self._published(client)
         cmd = self._published(client)
         assert "setting_id" not in cmd
         assert "setting_id" not in cmd
         assert cmd["extruder_id"] == 0
         assert cmd["extruder_id"] == 0
 
 
-    def test_p2s_serial_uses_single_nozzle_format(self):
+    def test_runtime_dual_nozzle_flag_uses_dual_format(self):
+        """When _is_dual_nozzle is set from device.extruder.info, the model
+        fallback isn't needed (covers future dual-nozzle models we haven't
+        seen yet)."""
+        client = self._make_client(serial="UNKNOWN", model=None, dual_runtime=True)
+        client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
+        cmd = self._published(client)
+        assert "setting_id" not in cmd
+
+    def test_h2s_uses_single_nozzle_format(self):
+        """H2S shares serial prefix "094" with H2D but is single-nozzle (#1386).
+        Must take the single-nozzle branch with setting_id included.
+        """
+        client = self._make_client(serial="09400S000000001", model="H2S")
+        client.delete_kprofile(
+            cali_idx=1,
+            filament_id="GFA00",
+            nozzle_id="HH00-0.4",
+            setting_id="PFB123",
+        )
+        cmd = self._published(client)
+        assert cmd["setting_id"] == "PFB123"
+
+    def test_p2s_uses_single_nozzle_format(self):
         """P2S is single-nozzle — must NOT take the dual-nozzle branch."""
         """P2S is single-nozzle — must NOT take the dual-nozzle branch."""
-        client = self._make_client("22E00A000000001")
+        client = self._make_client(serial="22E00A000000001", model="P2S")
         client.delete_kprofile(
         client.delete_kprofile(
             cali_idx=1,
             cali_idx=1,
             filament_id="GFA00",
             filament_id="GFA00",
@@ -3879,8 +3958,8 @@ class TestDeleteKProfileDualNozzleDetection:
         # Single-nozzle command includes setting_id.
         # Single-nozzle command includes setting_id.
         assert cmd["setting_id"] == "PFB123"
         assert cmd["setting_id"] == "PFB123"
 
 
-    def test_x1c_serial_uses_single_nozzle_format(self):
-        client = self._make_client("00M00A000000001")
+    def test_x1c_uses_single_nozzle_format(self):
+        client = self._make_client(serial="00M00A000000001", model="X1C")
         client.delete_kprofile(
         client.delete_kprofile(
             cali_idx=1,
             cali_idx=1,
             filament_id="GFA00",
             filament_id="GFA00",

Some files were not shown because too many files changed in this diff