فهرست منبع

Printers: a partial external spool push no longer empties the slot (#3513)

- the A1 and A1 mini report a change to the external spool with only
  the changed keys, often without the slot id; the push replaced the
  cached slot, so after a colour-only change it showed as empty
- a vt_tray dict without an id, or with the cached slot's, is merged
  into that slot; one that empties the slot or names another replaces
  it, and the H2 series' two slots are left as they were
- the assignment check also runs after an external spool push, which
  can come without AMS data, so the new preset is confirmed
maziggy 12 ساعت پیش
والد
کامیت
ebfc0efa85
2فایلهای تغییر یافته به همراه113 افزوده شده و 0 حذف شده
  1. 27 0
      backend/app/services/bambu_mqtt.py
  2. 86 0
      backend/tests/unit/test_external_slot_partial_push_3513.py

+ 27 - 0
backend/app/services/bambu_mqtt.py

@@ -558,6 +558,23 @@ def apply_tray_exist_bits(
     return cleared
 
 
+def _external_slot_to_merge(pushed: object, existing: object) -> dict | None:
+    """The one cached external slot a pushed ``vt_tray`` dict updates, or None (#3513).
+
+    It updates the slot when it names no slot or the cached one's. A push that
+    empties the slot (``tray_type`` ``""``) replaces it, so a removed spool
+    keeps no preset, K value or temperatures, as does one naming another slot.
+    """
+    if not isinstance(pushed, dict) or not isinstance(existing, list) or len(existing) != 1:
+        return None
+    cached = existing[0]
+    if not isinstance(cached, dict) or pushed.get("tray_type") == "":
+        return None
+    if "id" in pushed and str(pushed["id"]) != str(cached.get("id")):
+        return None
+    return cached
+
+
 # --- H2C nozzle-rack dispatch mapping (#2800) -------------------------------
 #
 # Physical nozzle IDs the H2C reports for its six rack slots, verified on
@@ -2760,6 +2777,12 @@ class BambuMQTTClient:
                 # Don't let a single-spool vt_tray dict overwrite multi-slot vir_slot data
                 if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1:
                     pass  # Keep the vir_slot data
+                elif (cached_slot := _external_slot_to_merge(vt_tray, existing)) is not None:
+                    # A delta carries only what changed, often without the
+                    # id: a colour-only change on an A1 mini pushes just
+                    # {"tray_color": ...}. Stored as it came, it emptied the
+                    # slot until the next full push (#3513).
+                    self.state.raw_data["vt_tray"] = [{**cached_slot, **vt_tray}]
                 else:
                     if isinstance(vt_tray, dict):
                         vt_tray = [vt_tray]
@@ -2772,6 +2795,10 @@ class BambuMQTTClient:
             # stale inventory assignment on the ams_id=255 slot (#2575). Detect
             # external-spool identity changes here and fire the same callback.
             self._maybe_trigger_external_spool_change()
+            # The external slot's own read-back: its delta can come without
+            # any AMS data, which is where the check otherwise runs (#3513).
+            if "vt_tray" in print_data and self._pending_assignments:
+                self._check_assignment_verifications()
 
             # Parse ams_status directly from print data (NOT from print.ams)
             # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status

+ 86 - 0
backend/tests/unit/test_external_slot_partial_push_3513.py

@@ -0,0 +1,86 @@
+"""A partial vt_tray push updates the external slot instead of replacing it (#3513).
+
+The A1 and A1 mini report a change to the external spool as a delta with only
+the changed keys, often without the slot id: a colour-only change pushes just
+``{"tray_color": ...}``. Stored as it came, it emptied the slot (no type, no
+preset) until the next full push, and without its id the assignment check
+could not find slot 254 and timed out on the old preset.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+pytestmark = pytest.mark.unit
+
+# The reporter's slot after a full push.
+FULL = {
+    "id": "254",
+    "tray_type": "PLA",
+    "tray_sub_brands": "",
+    "tray_color": "C0C0C0FF",
+    "tray_info_idx": "P0ea739d",
+    "nozzle_temp_min": "190",
+    "nozzle_temp_max": "230",
+    "k": 0.02,
+    "cali_idx": 5,
+    "remain": 0,
+}
+
+
+def _client(model: str = "A1 mini") -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="SN", access_code="c", model=model)
+    client._process_message({"print": {"command": "push_status", "vt_tray": dict(FULL)}})
+    return client
+
+
+def _push(client: BambuMQTTClient, vt_tray) -> dict:
+    client._process_message({"print": {"command": "push_status", "vt_tray": vt_tray}})
+    return client.state.raw_data["vt_tray"]
+
+
+class TestTheDelta:
+    def test_a_colour_only_change_keeps_the_rest_of_the_slot(self):
+        client = _client()
+
+        slots = _push(client, {"tray_color": "FFFFFFFF"})
+
+        assert slots == [{**FULL, "tray_color": "FFFFFFFF"}]
+        assert client._find_verify_tray(255, 0) is slots[0]
+
+    def test_the_same_id_merges_too(self):
+        slots = _push(_client(), {"id": "254", "tray_info_idx": "GFL99"})
+
+        assert slots == [{**FULL, "tray_info_idx": "GFL99"}]
+
+    def test_the_delta_confirms_a_pending_assignment(self):
+        client = _client()
+        seen = MagicMock()
+        client.on_assignment_verified = seen
+        client.register_assignment_verification(255, 0, "GFL03", "FFFFFFFF", None)
+
+        _push(client, {"tray_info_idx": "GFL03", "tray_color": "FFFFFFFF"})
+
+        seen.assert_called_once()
+        assert seen.call_args.args[:3] == (255, 0, True)
+
+
+class TestWholesale:
+    def test_an_emptied_slot_keeps_nothing_of_the_spool(self):
+        emptied = {"id": "254", "tray_type": "", "tray_color": "", "tray_info_idx": ""}
+
+        assert _push(_client(), emptied) == [emptied]
+
+    def test_another_slot_id_replaces_it(self):
+        other = {"id": "255", "tray_type": "PETG"}
+
+        assert _push(_client(), other) == [other]
+
+    def test_two_external_slots_ignore_a_single_dict(self):
+        client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="SN", access_code="c", model="H2D")
+        both = [{**FULL, "id": "254"}, {**FULL, "id": "255", "tray_type": "PETG"}]
+        client._process_message({"print": {"command": "push_status", "vir_slot": [dict(s) for s in both]}})
+
+        assert _push(client, {"tray_color": "FFFFFFFF"}) == both