Explorar el Código

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 hace 12 horas
padre
commit
ebfc0efa85

+ 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