Browse Source

Fix external spool ams_mapping2 using wrong ams_id on single-nozzle printers (#859)

  Single-nozzle printers (X1C, P1S, A1) report tray_now=254 for external
  spool, but BambuStudio sends ams_id=255 (VIRTUAL_TRAY_MAIN_ID) in the
  print command's ams_mapping2 field. Bambuddy was passing 254 as-is,
  causing firmware to target AMS tray 0 instead of external spool —
  resulting in 07FF_8012 "Failed to get AMS mapping table" or prints stuck
  at heatbed heating when an AMS is connected but empty.

  Map external spool to ams_id=255 for all non-H2D printers. H2D
  dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.
maziggy 1 month ago
parent
commit
ad96337587
3 changed files with 47 additions and 15 deletions
  1. 1 1
      CHANGELOG.md
  2. 15 9
      backend/app/services/bambu_mqtt.py
  3. 31 5
      backend/tests/unit/services/test_bambu_mqtt.py

+ 1 - 1
CHANGELOG.md

@@ -22,7 +22,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Fixed
 - **Filament Color and Subtype Inconsistencies** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like "Dark Gray" instead of Bambu-specific names like "Titan Gray" because the fallback skipped the Bambu hex color database. (2) "Silk+" subtype was missing from the known variants list, so the Edit Spool dropdown showed "Silk" instead. Also added "Tough+". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as "Basic" and PLA Silk Dual Color as "Silk" because the firmware only sends the base material in `tray_sub_brands`. Now detects gradient/multi-color/tri-color variants from the `tray_id_name` color code pattern (M\*/T\* suffixes).
-- **External Spool Print Fails on P1S/P1P Without AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854)) — Sending a print job to a printer with no AMS units and only an external spool (virtual tray 254) caused the printer to reject the command with "Failed to get AMS mapping table". The print command was sent with `use_ams: true` (the default), but firmware on printers without AMS hardware rejects that combination. Now automatically sets `use_ams: false` when all filament slots map to external spools or are unmapped. H2D-series printers are excluded since they use `use_ams` for nozzle routing.
+- **External Spool Print Fails on Printers With AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854), [#859](https://github.com/maziggy/bambuddy/issues/859)) — Two related issues with external spool printing: (1) Sending a print to a printer with no AMS units and only an external spool caused "Failed to get AMS mapping table" because the command was sent with `use_ams: true`. Now automatically sets `use_ams: false` when all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C with `ams_exist_bits=1, tray_exist_bits=0`) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command used `ams_id: 254` in `ams_mapping2` instead of `255`. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio uses `ams_id: 255` (VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool to `ams_id: 255` on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.
 - **External Folder Scan 500 Error on 3MF Files** ([#846](https://github.com/maziggy/bambuddy/issues/846)) — Scanning an external folder containing .3mf files crashed with "Object of type bytes is not JSON serializable". The parsed 3MF metadata contained raw thumbnail bytes (`_thumbnail_data`) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent `parser.extract_thumbnail()` method — thumbnail data is already available in the parsed metadata. Now uses the same `clean_metadata()` pattern as upload and zip extraction.
 - **Archives Capped at 50 Items** ([#843](https://github.com/maziggy/bambuddy/issues/843)) — The archives page only showed the 50 most recent prints due to a hardcoded API limit. Users with more than 50 archives could not see or access older entries. Fixed by fetching all archives and adding client-side pagination with configurable page sizes (25, 50, 100, 200, or All). Page size preference is persisted.
 - **Filament Usage Not Recorded When Auto-Archive Disabled** — When a printer had "Auto-archive completed prints" turned off, filament consumption was silently lost. The `on_print_complete` callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.

+ 15 - 9
backend/app/services/bambu_mqtt.py

@@ -2797,6 +2797,12 @@ 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")
+
             # 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
@@ -2813,13 +2819,19 @@ class BambuMQTTClient:
                         flat_ams_mapping.append(-1)
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                     elif tray_id >= 254:
-                        # External/virtual spool: each virtual tray is its own AMS unit
-                        # with a single slot (slot 0). BambuStudio convention:
+                        # External/virtual spool. BambuStudio convention:
                         #   255 = VIRTUAL_TRAY_MAIN_ID (main/right nozzle)
                         #   254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/left nozzle)
                         # Flat mapping must use -1 (firmware doesn't accept raw 254/255).
+                        # Single-nozzle printers (X1C, P1S, A1, etc.) report tray_now=254
+                        # for external spool, but BambuStudio always sends ams_id=255
+                        # (VIRTUAL_TRAY_MAIN_ID) in ams_mapping2. Sending 254 causes the
+                        # firmware to target AMS tray 0 instead of external spool, leading
+                        # 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)
-                        ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
+                        ext_ams_id = tray_id if is_h2d 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)
                         flat_ams_mapping.append(tray_id)
@@ -2831,12 +2843,6 @@ class BambuMQTTClient:
                         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
-            # 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")
-
             # 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.

+ 31 - 5
backend/tests/unit/services/test_bambu_mqtt.py

@@ -2919,13 +2919,18 @@ class TestStartPrintAmsMapping:
         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."""
+    def test_single_nozzle_external_spool_uses_main_id(self, mqtt_client):
+        """Single-nozzle external spool (254) maps to ams_id=255 (VIRTUAL_TRAY_MAIN_ID).
+
+        Firmware reports tray_now=254 for external spool, but the print command
+        must use ams_id=255 in ams_mapping2. Sending 254 causes the firmware to
+        target AMS tray 0 instead of external spool (07FF_8012 error).
+        """
         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}]
+        assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
 
     def test_h2d_external_spool_mixed_with_ams(self, mqtt_client):
         """H2D scenario: AMS trays + unmapped + external deputy nozzle."""
@@ -2956,8 +2961,20 @@ class TestStartPrintAmsMapping:
             {"ams_id": 131, "slot_id": 0},
         ]
 
-    def test_dual_nozzle_both_external(self, mqtt_client):
-        """Both nozzles using external spools: 254 (deputy) + 255 (main)."""
+    def test_non_h2d_both_external_maps_to_main_id(self, mqtt_client):
+        """Non-H2D: both 254 and 255 map to ams_id=255 (single nozzle)."""
+        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": 255, "slot_id": 0},
+            {"ams_id": 255, "slot_id": 0},
+        ]
+
+    def test_h2d_external_preserves_deputy_id(self, mqtt_client):
+        """H2D dual-nozzle: 254 (deputy) stays 254, 255 (main) stays 255."""
+        mqtt_client.model = "H2D"
         mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
 
         cmd = self._get_published_command(mqtt_client)
@@ -2967,6 +2984,15 @@ class TestStartPrintAmsMapping:
             {"ams_id": 255, "slot_id": 0},
         ]
 
+    def test_h2d_single_external_deputy(self, mqtt_client):
+        """H2D: single external spool on deputy nozzle (254) keeps ams_id=254."""
+        mqtt_client.model = "H2D Pro"
+        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_external_spool_only_sets_use_ams_false(self, mqtt_client):
         """Single external spool on non-H2D printer sets use_ams=False."""
         mqtt_client.start_print("test.3mf", ams_mapping=[254], use_ams=True)