Browse Source

Fix H2D external spool print failing with 0700_8012 (#797)

  BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
  ams_mapping and relies on ams_mapping2 for external spool routing.
  Bambuddy was passing raw 254/255 in the flat array, which H2D firmware
  rejects with "Failed to get AMS mapping table".

  - Convert external tray IDs to -1 in flat ams_mapping (match BambuStudio)
  - Fix ams_mapping2 for external trays: each virtual tray is its own AMS
    unit with slot_id 0, not a shared unit differentiated by slot
  - Fix main/deputy nozzle ID comment (255=main, 254=deputy per BambuStudio)
  - Add 8 unit tests for start_print() mapping construction
maziggy 2 months ago
parent
commit
5926e722f8
3 changed files with 134 additions and 5 deletions
  1. 1 0
      CHANGELOG.md
  2. 16 5
      backend/app/services/bambu_mqtt.py
  3. 117 0
      backend/tests/unit/services/test_bambu_mqtt.py

+ 1 - 0
CHANGELOG.md

@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Reported by @Utility9298.
 - **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Truly empty slots now hide both buttons, while slots with a spool inserted but filament not loaded still show configure/assign. Also fixed stale AMS slot data on H2D and other printers that only send `{id, state}` in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect. Reported by @RosdasHH.
 - **Log Flood: "State is FINISH but completion NOT triggered"** ([#790](https://github.com/maziggy/bambuddy/issues/790)) — A diagnostic log message introduced in 0.2.2.1 fired on every MQTT update while a printer sat in FINISH or FAILED state, flooding logs with thousands of lines per minute in printer farms. Fixed by only logging once on the initial state transition. Reported by @user.
+- **H2D External Spool Print Fails With "Failed to get AMS mapping table"** ([#797](https://github.com/maziggy/bambuddy/issues/797)) — Printing from an external spool on H2D (and H2D Pro) through Bambuddy failed with `0700_8012 "Failed to get AMS mapping table"`, while the same print worked fine from BambuStudio. Bambuddy was passing raw virtual tray IDs (254/255) in the flat `ams_mapping` array, but BambuStudio converts these to -1 and relies on `ams_mapping2` for external spool routing. The H2D firmware rejects raw 254/255 in the flat array. Also fixed the `ams_mapping2` format for external trays — each virtual tray is its own AMS unit with `slot_id: 0`, not a shared unit differentiated by slot. Reported by @Lukas-ESG.
 - **ffmpeg Process Leak Causing Memory Growth** ([#776](https://github.com/maziggy/bambuddy/issues/776)) — Camera stream ffmpeg processes accumulated over time, consuming several GB of RAM. When a user closed the camera viewer, the frontend sent a stop signal that killed the ffmpeg process, but the backend stream generator interpreted the dead process as a dropped connection and respawned ffmpeg — up to 30 reconnection attempts per stream. The orphan cleanup couldn't catch these because they were tracked as "active". Fixed by signaling the generator's disconnect event from the stop endpoint before killing the process, checking for stream removal before reconnecting, and tracking frame timestamps per-stream instead of per-printer so stale detection works correctly when multiple streams exist. Reported by @ChrisTheDBA, confirmed by @peter-k-de.
 
 ## [0.2.2.1] - 2026-03-22

+ 16 - 5
backend/app/services/bambu_mqtt.py

@@ -2649,25 +2649,36 @@ class BambuMQTTClient:
             # Bambu print command format - matches Bambu Studio's format
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
+            # BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
+            # ams_mapping and relies on ams_mapping2 for external spool details.
+            # Passing raw 254/255 in the flat array causes H2D firmware to fail
+            # with 0700_8012 "Failed to get AMS mapping table".
+            flat_ams_mapping = []
             if ams_mapping is not None:
                 for tray_id in ams_mapping:
                     # Ensure tray_id is an integer (may be string from JSON)
                     tray_id = int(tray_id) if tray_id is not None else -1
                     if tray_id == -1:
                         # Unmapped filament slot
+                        flat_ams_mapping.append(-1)
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                     elif tray_id >= 254:
-                        # External spool: 254 = main nozzle, 255 = deputy nozzle
-                        # For ams_mapping2, slot_id is 0 (main) or 1 (deputy), not the tray_id
-                        external_slot = 0 if tray_id == 254 else 1
-                        ams_mapping2.append({"ams_id": 255, "slot_id": external_slot})
+                        # External/virtual spool: each virtual tray is its own AMS unit
+                        # with a single slot (slot 0). BambuStudio convention:
+                        #   255 = VIRTUAL_TRAY_MAIN_ID (main/left nozzle)
+                        #   254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/right nozzle)
+                        # Flat mapping must use -1 (firmware doesn't accept raw 254/255).
+                        flat_ams_mapping.append(-1)
+                        ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                     elif tray_id >= 128:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
+                        flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                     else:
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         ams_id = tray_id // 4
                         slot_id = tray_id % 4
+                        flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
             # H2D series requires integer values (0/1) for calibration/leveling fields
@@ -2718,7 +2729,7 @@ class BambuMQTTClient:
 
             # Add AMS mapping if provided
             if ams_mapping is not None:
-                command["print"]["ams_mapping"] = ams_mapping
+                command["print"]["ams_mapping"] = flat_ams_mapping
                 command["print"]["ams_mapping2"] = ams_mapping2
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))

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

@@ -2857,3 +2857,120 @@ class TestSendDryingCommand:
         # qos may be positional arg [2] or keyword
         qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
         assert qos == 1
+
+
+class TestStartPrintAmsMapping:
+    """Tests for ams_mapping/ams_mapping2 construction in start_print().
+
+    BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
+    ams_mapping and puts the real external spool info only in ams_mapping2.
+    Passing raw 254/255 in the flat array causes H2D firmware to fail
+    with 0700_8012 "Failed to get AMS mapping table".
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _get_published_command(self, mqtt_client):
+        """Extract the parsed print command from the last publish call."""
+        call_args = mqtt_client._client.publish.call_args
+        return json.loads(call_args[0][1])["print"]
+
+    def test_regular_ams_trays_preserved_in_flat_mapping(self, mqtt_client):
+        """Regular AMS tray IDs pass through unchanged in flat ams_mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[0, 5, 11])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [0, 5, 11]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 0, "slot_id": 0},
+            {"ams_id": 1, "slot_id": 1},
+            {"ams_id": 2, "slot_id": 3},
+        ]
+
+    def test_unmapped_slots(self, mqtt_client):
+        """Unmapped slots (-1) produce -1 in flat and 0xFF/0xFF in mapping2."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1, -1]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+        ]
+
+    def test_external_main_nozzle_becomes_minus_one_in_flat(self, mqtt_client):
+        """Virtual tray 255 (main nozzle) must be -1 in flat mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[255])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1]
+        assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
+
+    def test_external_deputy_nozzle_becomes_minus_one_in_flat(self, mqtt_client):
+        """Virtual tray 254 (deputy nozzle) must be -1 in flat mapping."""
+        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": 254, "slot_id": 0}]
+
+    def test_h2d_external_spool_mixed_with_ams(self, mqtt_client):
+        """H2D scenario: AMS trays + unmapped + external deputy nozzle."""
+        # Reproduces the exact scenario from issue #797:
+        # 5-slot 3MF, only slot 5 assigned to external deputy nozzle (254)
+        mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1, -1, -1, 255])
+
+        cmd = self._get_published_command(mqtt_client)
+        # Flat mapping: all -1 (external converted, unmapped stay -1)
+        assert cmd["ams_mapping"] == [-1, -1, -1, -1, -1]
+        # Detailed mapping: unmapped slots use 0xFF, external uses real ams_id
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 255},
+            {"ams_id": 255, "slot_id": 0},
+        ]
+
+    def test_ams_ht_trays_preserved_in_flat_mapping(self, mqtt_client):
+        """AMS-HT tray IDs (>=128) pass through in flat mapping."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[128, 131])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [128, 131]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 128, "slot_id": 0},
+            {"ams_id": 131, "slot_id": 0},
+        ]
+
+    def test_dual_nozzle_both_external(self, mqtt_client):
+        """Both nozzles using external spools: 254 (deputy) + 255 (main)."""
+        mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1, -1]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 254, "slot_id": 0},
+            {"ams_id": 255, "slot_id": 0},
+        ]
+
+    def test_no_ams_mapping_omits_fields(self, mqtt_client):
+        """When ams_mapping is None, neither field is in the command."""
+        mqtt_client.start_print("test.3mf", ams_mapping=None)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert "ams_mapping" not in cmd
+        assert "ams_mapping2" not in cmd