Browse Source

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 1 hour ago
parent
commit
cd90e17331

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 
 ### Fixed
 ### Fixed
+- **The external spool keeps its filament after Configure Slot on an A1 or A1 mini (#3513, reported by @SaadShaya7)** — These printers report a change to the external spool with only what changed, often just the colour. Bambuddy stored that as the whole slot, so after a colour or preset change the spool showed as empty, with no filament type, until the printer's next full update. The check that the printer took the new preset also could not find the slot and reported it as not confirmed. A partial update now changes only what it carries, and the check confirms the new preset as soon as the printer reports it.
 - **A print that fails before its first layer no longer charges a whole spool (#3511)** — When a print failed or was cancelled before its first layer, Bambuddy worked out from the sliced file that no filament was used, but still let the AMS remaining % decide for that slot. The AMS can read 0% for a moment as the filament unloads, so a new spool at 100% was charged in full: 1000 g and its cost for a print stopped after five minutes, which then topped the Stats page as the heaviest and most expensive print. A slot whose spool the sliced file already accounts for is now left to that figure, so such a print charges nothing. Prints without a sliced file still use the AMS remaining %.
 - **A print that fails before its first layer no longer charges a whole spool (#3511)** — When a print failed or was cancelled before its first layer, Bambuddy worked out from the sliced file that no filament was used, but still let the AMS remaining % decide for that slot. The AMS can read 0% for a moment as the filament unloads, so a new spool at 100% was charged in full: 1000 g and its cost for a print stopped after five minutes, which then topped the Stats page as the heaviest and most expensive print. A slot whose spool the sliced file already accounts for is now left to that figure, so such a print charges nothing. Prints without a sliced file still use the AMS remaining %.
 - **The H3C's printer card shows the temperature of the hotend on the toolhead (#3442, reported by @hurr1k4ne, cause found by @daltonclift)** — After a tool swap the H3C keeps reporting the temperature of the hotend that just went back to the rack, so the card could show one cooling down there instead of the one printing. The card now takes the temperature, target and heating state of the hotend on the toolhead, as Bambu Studio does, and names it by its rack position (**Nozzle 5**), the number its chip has in the nozzle rack beside it.
 - **The H3C's printer card shows the temperature of the hotend on the toolhead (#3442, reported by @hurr1k4ne, cause found by @daltonclift)** — After a tool swap the H3C keeps reporting the temperature of the hotend that just went back to the rack, so the card could show one cooling down there instead of the one printing. The card now takes the temperature, target and heating state of the hotend on the toolhead, as Bambu Studio does, and names it by its rack position (**Nozzle 5**), the number its chip has in the nozzle rack beside it.
 - **The SpoolBuddy kiosk works with its own device when several are registered (#3504, reported by @pleasantone)** — The kiosk's **Write** page always sent the write to the first registered device, which could be another kiosk or an old reader that had long been offline. The online state and display settings also came from that device, and every kiosk showed the weight and tags of every reader. The kiosk now works with one device: the one picked on its **Write** page, or else the device that is online. **Write** shows a choice of device when more than one is online, and the kiosk remembers it. Weight, tags and online state from other devices no longer show on it.
 - **The SpoolBuddy kiosk works with its own device when several are registered (#3504, reported by @pleasantone)** — The kiosk's **Write** page always sent the write to the first registered device, which could be another kiosk or an old reader that had long been offline. The online state and display settings also came from that device, and every kiosk showed the weight and tags of every reader. The kiosk now works with one device: the one picked on its **Write** page, or else the device that is online. **Write** shows a choice of device when more than one is online, and the kiosk remembers it. Weight, tags and online state from other devices no longer show on it.

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

@@ -558,6 +558,23 @@ def apply_tray_exist_bits(
     return cleared
     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) -------------------------------
 # --- H2C nozzle-rack dispatch mapping (#2800) -------------------------------
 #
 #
 # Physical nozzle IDs the H2C reports for its six rack slots, verified on
 # 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
                 # 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:
                 if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1:
                     pass  # Keep the vir_slot data
                     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:
                 else:
                     if isinstance(vt_tray, dict):
                     if isinstance(vt_tray, dict):
                         vt_tray = [vt_tray]
                         vt_tray = [vt_tray]
@@ -2772,6 +2795,10 @@ class BambuMQTTClient:
             # stale inventory assignment on the ams_id=255 slot (#2575). Detect
             # stale inventory assignment on the ams_id=255 slot (#2575). Detect
             # external-spool identity changes here and fire the same callback.
             # external-spool identity changes here and fire the same callback.
             self._maybe_trigger_external_spool_change()
             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)
             # 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
             # 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