mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 04:31:28 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user