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.
This commit is contained in:
maziggy
2026-05-17 07:56:48 +02:00
parent 4cfa5f655a
commit 96fd4bb7e3
4 changed files with 158 additions and 47 deletions
+3
View File
@@ -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
+11 -8
View File
@@ -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,
+46 -20
View File
@@ -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
+98 -19
View File
@@ -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",