From 96fd4bb7e3d7d4350a77d65a4e708798969c8431 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 17 May 2026 07:56:48 +0200 Subject: [PATCH] =?UTF-8?q?=20=20fix(printer):=20H2S=20could=20not=20start?= =?UTF-8?q?=20prints=20without=20AMS=20=E2=80=94=20was=20misclassified=20a?= =?UTF-8?q?s=20dual-nozzle=20(#1386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 3 + backend/app/api/routes/kprofiles.py | 19 +-- backend/app/services/bambu_mqtt.py | 66 +++++++--- .../tests/unit/services/test_bambu_mqtt.py | 117 +++++++++++++++--- 4 files changed, 158 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd123252..f8b94fad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased +### Fixed +- **H2S with no AMS could not start a print — firmware rejected the dispatch with `07FF_8012` "Failed to get AMS mapping table" (#1386)** — Reporter krootstijn (H2S + no AMS) clicked Print and got an immediate firmware error. Two stacked misclassifications had quietly added H2S to the dual-nozzle code paths over time. The first was in `start_print_job` at `backend/app/services/bambu_mqtt.py:3168` — the `is_h2d` flag was set true for `("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")`. That single flag controlled both the firmware bool→int format (legitimately needed for the whole H-family) *and* the external-spool routing branch (`ext_ams_id = tray_id if is_h2d else 255`) which is only correct for actual dual-nozzle printers. With no AMS, the external-spool sentinel is `254`; the dual-nozzle branch wrote `ams_id=254` into `ams_mapping2` instead of the canonical `255`. The exact failure shape (`07FF_8012`) is even called out in the comment six lines above the bad line — H2S was getting routed straight into the path the comment warned against. The second misclassification was the use_ams=False fallback at `bambu_mqtt.py:3213` (`if ams_mapping and use_ams and not is_h2d`) — meant to skip the safety drop on dual-nozzle printers where `use_ams` controls nozzle routing — also skipped H2S, so the firmware never got a chance to fall back to external-spool mode. A third site at `bambu_mqtt.py:3987` (and its sibling at `backend/app/api/routes/kprofiles.py:119`) classified dual-nozzle by serial prefix `("094", "20P9", "31B8B")`, which is wrong because H2S shares prefix `094` with H2D. Fix splits the conflated flag into two: `is_h_family` (firmware-format gate, includes H2S) and `is_dual_nozzle` (routing/use_ams gate, excludes H2S; prefers the runtime `_is_dual_nozzle` flag set from `device.extruder.info` and falls back to model name for the brief window right after connect). The K-profile delete and the edit route now use the same two-source check instead of the serial prefix. Empirically verified across 9+ stored H2S support bundles (`nozzle_count: 1` in every one) and the reporter's bug log (`07FF_8012` immediately after dispatch). Four new regression tests: `test_h2s_single_external_spool_uses_main_id`, `test_h2s_no_ams_forces_use_ams_false`, `test_h2s_keeps_integer_format_for_calibration_fields`, plus a new `test_h2s_uses_single_nozzle_format` in the K-profile suite. The K-profile detection tests were also updated to set both model name and runtime flag rather than relying on serial prefix, since the source-of-truth has shifted. + ## [0.2.4.1] - 2026-05-16 ### Changed diff --git a/backend/app/api/routes/kprofiles.py b/backend/app/api/routes/kprofiles.py index 316e5053e..ef8534da8 100644 --- a/backend/app/api/routes/kprofiles.py +++ b/backend/app/api/routes/kprofiles.py @@ -113,14 +113,17 @@ async def set_kprofile( if not client or not client.state.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")) + # 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_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) + 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( filament_id=profile.filament_id, name=profile.name, @@ -133,7 +136,7 @@ async def set_kprofile( cali_idx=profile.slot_id, # Pass the original slot for in-place 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) delete_success = client.delete_kprofile( cali_idx=profile.slot_id, diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index b9a76449c..b39c4cfd0 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -3161,11 +3161,31 @@ class BambuMQTTClient: """ if self._client and self.state.connected: # 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) ams_mapping2 = [] @@ -3194,7 +3214,7 @@ class BambuMQTTClient: # to 07FF_8012 "Failed to get AMS mapping table" or stuck prints. # Only H2D dual-nozzle printers use 254 (deputy/left nozzle). 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}) elif tray_id >= 128: # 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. # 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): use_ams = False logger.info( @@ -3247,12 +3270,12 @@ class BambuMQTTClient: "file": filename, "md5": "", "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, - "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, "cfg": "0", "extrude_cali_flag": 0, @@ -3266,9 +3289,9 @@ class BambuMQTTClient: } } - if is_h2d: + if is_h_family: 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, ) @@ -3980,11 +4003,14 @@ class BambuMQTTClient: 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: # H2D format: uses extruder_id, nozzle_id, nozzle_diameter diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 9e359f49c..41d6a0a39 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -3696,9 +3696,9 @@ class TestStartPrintAmsMapping: assert cmd["layer_inspect"] == 1 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. """ mqtt_client.model = "P2S" @@ -3708,6 +3708,58 @@ class TestStartPrintAmsMapping: assert cmd["timelapse"] is True 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: """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011). @@ -3818,15 +3870,16 @@ class TestStartPrintUniqueIdentityFields: 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 backend.app.services.bambu_mqtt import BambuMQTTClient @@ -3838,37 +3891,63 @@ class TestDeleteKProfileDualNozzleDetection: ) client._client = MagicMock() client.state.connected = True + client.model = model + client._is_dual_nozzle = dual_runtime return client def _published(self, client): 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") cmd = self._published(client) # Dual-nozzle command omits setting_id. assert "setting_id" not in cmd 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") cmd = self._published(client) assert "setting_id" not in cmd 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") cmd = self._published(client) assert "setting_id" not in cmd 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.""" - client = self._make_client("22E00A000000001") + client = self._make_client(serial="22E00A000000001", model="P2S") client.delete_kprofile( cali_idx=1, filament_id="GFA00", @@ -3879,8 +3958,8 @@ class TestDeleteKProfileDualNozzleDetection: # Single-nozzle command includes setting_id. 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( cali_idx=1, filament_id="GFA00",