Browse Source

fix: H2D tray_now disambiguation produces bogus IDs for AMS-HT (#364)

When the snow field hadn't arrived yet, the ams_extruder_map fallback
computed ams_id * 4 + slot for AMS-HT units (128-135), producing
invalid tray IDs like 512+. AMS-HT has a single slot; global ID = unit
ID.

- Single-AMS fallback: return ams_id directly for AMS-HT (128-135)
- Multi-AMS matching: decompose AMS-HT trays correctly (slot=0)
- Multi-AMS narrowing: slot > 0 eliminates AMS-HT candidates
- last_loaded_tray: tighten to valid IDs only (0-15, 128-135, 254)
- Add 10 tests for AMS-HT fallback and last_loaded_tray validation
maziggy 3 months ago
parent
commit
c856badb84
3 changed files with 127 additions and 11 deletions
  1. 1 0
      CHANGELOG.md
  2. 40 11
      backend/app/services/bambu_mqtt.py
  3. 86 0
      backend/tests/unit/services/test_bambu_mqtt.py

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.
 - **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
 - **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
+- **H2D Tray Disambiguation Produces Bogus tray_now for AMS-HT** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When the snow field hadn't arrived yet on H2D dual-nozzle printers, the `ams_extruder_map` fallback computed `ams_id * 4 + slot` for all AMS types — including AMS-HT units (IDs 128-135) which have a single slot and use their unit ID as the global tray ID. This produced bogus values like 512+ that briefly appeared in the UI and could pollute `last_loaded_tray`. Now correctly returns the AMS-HT unit ID for single-slot units, handles AMS-HT in multi-AMS matching, filters AMS-HT candidates when slot > 0, and tightens `last_loaded_tray` to only accept physically valid tray IDs (0-15, 128-135, 254).
 - **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
 - **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.
 

+ 40 - 11
backend/app/services/bambu_mqtt.py

@@ -990,29 +990,56 @@ class BambuMQTTClient:
                             if len(ams_on_extruder) == 1:
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
-                                global_tray_id = active_ams_id * 4 + parsed_tray_now
+                                if 128 <= active_ams_id <= 135:
+                                    # AMS-HT: single slot per unit, global ID = unit ID
+                                    global_tray_id = active_ams_id
+                                else:
+                                    global_tray_id = active_ams_id * 4 + parsed_tray_now
                                 logger.debug(
                                     f"[{self.serial_number}] H2D tray_now fallback: "
                                     f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}"
                                 )
                                 self.state.tray_now = global_tray_id
                             elif len(ams_on_extruder) > 1:
-                                # Multiple AMS on this extruder - keep current if valid, else use slot as-is
+                                # Multiple AMS on this extruder - keep current if valid, else try to narrow down
                                 current_tray = self.state.tray_now
-                                current_ams = current_tray // 4 if current_tray < 128 else -1
-                                if current_ams in ams_on_extruder and (current_tray % 4) == parsed_tray_now:
+                                # Determine which AMS unit and slot the current tray belongs to
+                                if 0 <= current_tray <= 15:
+                                    current_ams = current_tray // 4
+                                    current_slot = current_tray % 4
+                                elif 128 <= current_tray <= 135:
+                                    current_ams = current_tray  # AMS-HT: ID = tray ID
+                                    current_slot = 0
+                                else:
+                                    current_ams = -1
+                                    current_slot = -1
+                                if current_ams in ams_on_extruder and current_slot == parsed_tray_now:
                                     # Current is valid and matches slot - keep it
                                     logger.debug(
                                         f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
                                         f"keeping current {current_tray} (matches slot {parsed_tray_now})"
                                     )
                                 else:
-                                    # Can't disambiguate - use slot as-is (will be wrong for non-first AMS)
-                                    logger.warning(
-                                        f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, "
-                                        f"no snow field, using slot {parsed_tray_now} (may be incorrect)"
-                                    )
-                                    self.state.tray_now = parsed_tray_now
+                                    # Filter candidates: AMS-HT (128-135) only valid for slot 0
+                                    if parsed_tray_now > 0:
+                                        candidates = [a for a in ams_on_extruder if a <= 3]
+                                    else:
+                                        candidates = ams_on_extruder
+                                    if len(candidates) == 1:
+                                        cand = candidates[0]
+                                        resolved = cand if 128 <= cand <= 135 else cand * 4 + parsed_tray_now
+                                        logger.debug(
+                                            f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
+                                            f"narrowed to AMS {cand} -> global ID {resolved}"
+                                        )
+                                        self.state.tray_now = resolved
+                                    else:
+                                        # Genuinely ambiguous - use slot as-is (will be wrong for non-first AMS)
+                                        logger.warning(
+                                            f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, "
+                                            f"no snow field, using slot {parsed_tray_now} (may be incorrect)"
+                                        )
+                                        self.state.tray_now = parsed_tray_now
                             else:
                                 # No AMS on this extruder - use slot as-is
                                 logger.warning(
@@ -1029,7 +1056,9 @@ class BambuMQTTClient:
                     self.state.tray_now = parsed_tray_now
 
                 # Track last valid tray for usage tracking (survives retract → 255 at print end)
-                if 0 <= self.state.tray_now <= 253:
+                # Valid physical trays: 0-15 (regular AMS), 128-135 (AMS-HT), 254 (external spool)
+                tn = self.state.tray_now
+                if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
                     self.state.last_loaded_tray = self.state.tray_now
 
                 logger.debug("[%s] tray_now updated: %s", self.serial_number, self.state.tray_now)

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

@@ -1632,6 +1632,92 @@ class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
         h2d_client._process_message(_ams_payload(2))
         assert h2d_client.state.tray_now == 2
 
+    def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):
+        """AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512)."""
+        # Switch to left extruder (where AMS-HT 128 is mapped)
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        # Only AMS-HT 128 on left extruder; no snow available
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_single_ams_ht_ignores_nonzero_slot(self, h2d_client):
+        """AMS-HT has single slot; even if printer reports slot 1, global ID = unit ID."""
+        h2d_client.state.ams_extruder_map = {"129": 0}
+        h2d_client._process_message(_ams_payload(1))
+        # AMS-HT 129: global ID = 129, not 129*4+1=517
+        assert h2d_client.state.tray_now == 129
+
+    def test_multiple_ams_keeps_current_ams_ht(self, h2d_client):
+        """Current tray is AMS-HT 128, slot 0 reported → keeps 128."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
+        h2d_client.state.tray_now = 128
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_multiple_ams_slot_nonzero_excludes_ams_ht(self, h2d_client):
+        """Slot > 0 eliminates AMS-HT candidates; single regular AMS left → resolves."""
+        # AMS 0 + AMS-HT 128 both on right extruder
+        h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
+        h2d_client.state.tray_now = 255  # no current match
+        # Slot 2 → can't be AMS-HT → only AMS 0 → global = 0*4+2 = 2
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_multiple_ams_slot_nonzero_narrows_to_single_ht_excluded(self, h2d_client):
+        """Two regular AMS + one AMS-HT, slot > 0 → AMS-HT excluded but still ambiguous."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "128": 0}
+        h2d_client.state.tray_now = 255
+        # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 3  # raw slot fallback
+
+
+# ---------------------------------------------------------------------------
+# 6b. H2D last_loaded_tray validation
+# ---------------------------------------------------------------------------
+
+
+class TestLastLoadedTrayValidation(_H2DFixtureMixin):
+    """last_loaded_tray only stores physically valid tray IDs."""
+
+    def test_regular_ams_tray_stored(self, h2d_client):
+        """Valid regular AMS tray (0-15) → stored in last_loaded_tray."""
+        h2d_client.state.tray_now = 7
+        # Trigger tray_now processing via AMS message
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 1 << 8 | 3},  # AMS 1 slot 3 → global 7
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 7
+        assert h2d_client.state.last_loaded_tray == 7
+
+    def test_ams_ht_tray_stored(self, h2d_client):
+        """Valid AMS-HT tray (128-135) → stored in last_loaded_tray."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+        assert h2d_client.state.last_loaded_tray == 128
+
+    def test_unloaded_not_stored(self, h2d_client):
+        """tray_now=255 (unloaded) → last_loaded_tray unchanged."""
+        h2d_client.state.last_loaded_tray = 5
+        h2d_client._process_message(_ams_payload(255))
+        assert h2d_client.state.tray_now == 255
+        assert h2d_client.state.last_loaded_tray == 5
+
 
 # ---------------------------------------------------------------------------
 # 7. H2D Active extruder switching