فهرست منبع

fix(vp): deep-merge ams on bridge cache so P1S/A1 partial pushes don't nuke AMS (#1387)

  Reporter vmhomelab ran a Print Queue VP against a P1S, opened
  BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch
  (which restarts the VP) made AMS briefly appear, then it reverted to
  defaults. Proxy Mode worked fine.

  The earlier #1371 sticky-keys fix only handled one of two firmware
  incremental-push shapes: it preserved cached `ams` when the incoming
  push OMITTED the key entirely. P1S firmware (01.09.01.00) instead
  sends incrementals with the `ams` key present but the inner `ams.ams`
  array stripped — `{ams_status: 1, humidity: 2}` rather than
  `{ams: [...], ams_status: 1}`. To the existing "key present? leave it"
  check that read as "no need to preserve," so the bridge cache got
  overwritten with the stripped blob, the slicer's next 1 Hz read saw
  `ams` with no unit list, and BambuStudio fell back to its "no AMS"
  default render. Toggling Auto-Dispatch restarted the VP and got a
  fresh pushall through; the next P1S incremental stripped it again.

  H2D rarely trips this because its incrementals typically don't carry
  `ams` at all, so #1371 alone was enough — which is why H2D users
  (including the project owner) didn't see the bug while P1S/A1 users do.

  Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the
  structure Bambuddy itself already does in
  `bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value,
  but the `ams.ams` array is merged unit-by-unit by `id`, each unit's
  `tray` array is merged tray-by-tray by `id`, and units / trays the
  incremental doesn't mention survive intact from the cached full
  state. A tray-targeted incremental during a print
  (`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one
  tray's state without dropping the other trays' tray_type / tray_color.

  Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called
  from the existing sticky-keys block when both prev and new carry the
  `ams` key as dicts. Other sticky keys (vt_tray, net, ipcam,
  lights_report, ams_extruder_map, mapping) keep the prior absent-only
  preservation; only `ams` has the multi-shape partial problem worth
  the merge complexity.
maziggy 1 هفته پیش
والد
کامیت
1bb0d4856d
3فایلهای تغییر یافته به همراه303 افزوده شده و 2 حذف شده
  1. 0 0
      CHANGELOG.md
  2. 120 2
      backend/app/services/virtual_printer/mqtt_bridge.py
  3. 183 0
      backend/tests/unit/test_vp_mqtt_bridge.py

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 120 - 2
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -76,6 +76,110 @@ def _ip_to_uint32_le(ip_str: str) -> int:
     return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
 
 
+def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
+    """Merge a new ``ams`` blob from an incremental push onto the previous one.
+
+    Bambu firmware sends three shapes for the ``ams`` field on push_status:
+
+    1. Full pushall (after a printer reconnect or explicit pushall request):
+       ``{ams: [{id, tray: [{id, tray_type, ...}, ...]}, ...], ams_status, ams_exist_bits, ...}``
+       — every unit + every tray populated.
+
+    2. Status-only incremental: ``{ams_status: 1}`` or ``{humidity: 30}`` —
+       no ``ams`` array at all. Bambuddy logs these as "AMS partial update
+       (no tray data)" (#784 vintage).
+
+    3. Tray-targeted incremental during a print: ``{ams: [{id: 0, tray:
+       [{id: 0, state: 11}]}]}`` — only the units / trays whose state
+       changed.
+
+    Replacing the cached ``ams`` wholesale on shapes (2) and (3) is what
+    made the slicer "lose" AMS between pushalls and trip the symptom in
+    #1387: the slicer would see a stripped ``ams_status``-only blob and
+    fall back to its "no AMS" default render. This merge mirrors the
+    deep-merge logic in ``bambu_mqtt.py::_handle_ams_data`` at the bridge
+    layer so the slicer-facing cache always carries the latest known
+    coherent state.
+
+    Strategy:
+      - Shallow-merge top-level scalars: keys in ``new`` win; keys only
+        in ``prev`` are preserved.
+      - For the ``ams`` array (list of units): match by ``id``. Units
+        only in ``prev`` survive. Units in ``new`` overlay onto their
+        ``prev`` counterpart; same recursion applies to each unit's
+        ``tray`` array by tray ``id``.
+    """
+    merged = dict(prev_ams)
+    for k, v in new_ams.items():
+        if k != "ams":
+            merged[k] = v
+
+    prev_units = prev_ams.get("ams") if isinstance(prev_ams.get("ams"), list) else []
+    new_units = new_ams.get("ams") if isinstance(new_ams.get("ams"), list) else None
+    if new_units is None:
+        # Shape (2): no ``ams`` array in the incremental — keep prev's units.
+        if prev_units:
+            merged["ams"] = prev_units
+        return merged
+
+    prev_by_id = {u.get("id"): u for u in prev_units if isinstance(u, dict) and u.get("id") is not None}
+    merged_units: list = []
+    seen_ids: set = set()
+    for new_unit in new_units:
+        if not isinstance(new_unit, dict):
+            merged_units.append(new_unit)
+            continue
+        uid = new_unit.get("id")
+        prev_unit = prev_by_id.get(uid) if uid is not None else None
+        if prev_unit is None:
+            merged_units.append(new_unit)
+            if uid is not None:
+                seen_ids.add(uid)
+            continue
+        # Shallow-merge unit fields; preserve prev's trays not present in new.
+        merged_unit = dict(prev_unit)
+        for k, v in new_unit.items():
+            if k != "tray":
+                merged_unit[k] = v
+        new_trays = new_unit.get("tray") if isinstance(new_unit.get("tray"), list) else None
+        if new_trays is None:
+            # Unit-level partial — keep prev's tray list intact.
+            pass
+        else:
+            prev_trays = prev_unit.get("tray") if isinstance(prev_unit.get("tray"), list) else []
+            prev_trays_by_id = {t.get("id"): t for t in prev_trays if isinstance(t, dict) and t.get("id") is not None}
+            merged_trays: list = []
+            seen_tray_ids: set = set()
+            for new_tray in new_trays:
+                if not isinstance(new_tray, dict):
+                    merged_trays.append(new_tray)
+                    continue
+                tid = new_tray.get("id")
+                prev_tray = prev_trays_by_id.get(tid) if tid is not None else None
+                if prev_tray is None:
+                    merged_trays.append(new_tray)
+                else:
+                    merged_tray = dict(prev_tray)
+                    merged_tray.update(new_tray)
+                    merged_trays.append(merged_tray)
+                if tid is not None:
+                    seen_tray_ids.add(tid)
+            # Preserve prev trays not mentioned in the incremental.
+            for tid, prev_tray in prev_trays_by_id.items():
+                if tid not in seen_tray_ids:
+                    merged_trays.append(prev_tray)
+            merged_unit["tray"] = merged_trays
+        merged_units.append(merged_unit)
+        if uid is not None:
+            seen_ids.add(uid)
+    # Preserve prev units not mentioned in the incremental.
+    for uid, prev_unit in prev_by_id.items():
+        if uid not in seen_ids:
+            merged_units.append(prev_unit)
+    merged["ams"] = merged_units
+    return merged
+
+
 class MQTTBridge:
     """Per-VP MQTT fan-out between a real printer and slicers connected to a VP."""
 
@@ -296,8 +400,22 @@ class MQTTBridge:
             prev = self._latest_print_state
             if prev is not None:
                 for sticky_key in _SLICER_VISIBLE_STICKY_KEYS:
-                    if sticky_key not in new_state and sticky_key in prev:
-                        new_state[sticky_key] = prev[sticky_key]
+                    if sticky_key not in new_state:
+                        if sticky_key in prev:
+                            new_state[sticky_key] = prev[sticky_key]
+                        continue
+                    # Key IS in new_state — but firmware sends partial blobs
+                    # (status-only / tray-targeted) under the same key on
+                    # incremental updates, which would overwrite the cached
+                    # full blob and break the slicer's AMS render (#1387).
+                    # For `ams` specifically the deep-merge mirrors what
+                    # Bambuddy already does internally in `_handle_ams_data`.
+                    if (
+                        sticky_key == "ams"
+                        and isinstance(new_state.get("ams"), dict)
+                        and isinstance(prev.get("ams"), dict)
+                    ):
+                        new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
             self._latest_print_state = new_state
             return
 

+ 183 - 0
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -306,6 +306,189 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_partial_ams_status_update_preserves_unit_list(self):
+        """#1387: Bambu firmware also sends `ams` updates where the key is
+        present but the inner `ams` array is missing — e.g. just
+        ``{ams_status: 1}`` or a humidity change. Before the deep-merge fix
+        the bridge would overwrite the cached AMS with this stripped blob,
+        the slicer would read it on the next 1 Hz push, and BambuStudio
+        would drop the unit list and fall back to its "no AMS" render
+        (only the external spool visible — the reporter's exact symptom).
+        Now the partial update only mutates the fields it carries; the
+        cached unit list survives.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Pushall with full AMS state.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "0",
+                                    "humidity": "1",
+                                    "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}],
+                                }
+                            ],
+                            "tray_exist_bits": "1",
+                            "ams_status": "0",
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        # 2. Partial AMS update — only `ams_status` and `humidity` changed.
+        # No `ams.ams` array, so prev's unit list must be preserved.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams_status": "1", "humidity": "2"},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        # Scalar fields take the new values.
+        assert cached["ams"]["ams_status"] == "1"
+        assert cached["ams"]["humidity"] == "2"
+        # Unit + tray data preserved from the pushall.
+        assert cached["ams"]["tray_exist_bits"] == "1"
+        assert len(cached["ams"]["ams"]) == 1
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_color"] == "FF0000FF"
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_partial_ams_unit_update_preserves_other_units(self):
+        """#1387: when multiple AMS units are configured (e.g. H2D with two
+        AMS), an incremental push during a print typically only carries the
+        unit / tray that changed state. Naive replacement of `ams.ams` wipes
+        the other unit. The bridge merges unit-by-unit by id, preserving
+        units the incremental doesn't mention.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Pushall with two AMS units configured.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
+                                {"id": "1", "tray": [{"id": "0", "tray_type": "PETG"}]},
+                            ],
+                            "tray_exist_bits": "3",
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        # 2. Tray-targeted incremental: unit 0 / tray 0 state changed.
+        # Unit 1 is not in the update — must survive.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        units = {u["id"]: u for u in cached["ams"]["ams"]}
+        # Unit 0 keeps its tray_type from the pushall + picks up the new state.
+        assert units["0"]["tray"][0]["tray_type"] == "PLA"
+        assert units["0"]["tray"][0]["state"] == "11"
+        # Unit 1 survives the incremental.
+        assert "1" in units
+        assert units["1"]["tray"][0]["tray_type"] == "PETG"
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_partial_ams_tray_update_preserves_other_trays(self):
+        """Same shape as the unit-level test but at the tray level. AMS
+        unit 0 has four trays; the incremental only mentions tray 0.
+        Trays 1-3 must survive intact."""
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "0",
+                                    "tray": [
+                                        {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
+                                        {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
+                                        {"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
+                                        {"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
+                                    ],
+                                }
+                            ],
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        trays = {t["id"]: t for t in cached["ams"]["ams"][0]["tray"]}
+        assert trays["0"]["tray_type"] == "PLA"
+        assert trays["0"]["state"] == "11"
+        # Trays not mentioned in the incremental survive intact.
+        assert trays["1"]["tray_type"] == "PETG"
+        assert trays["2"]["tray_type"] == "ABS"
+        assert trays["3"]["tray_type"] == "TPU"
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     async def test_incoming_ams_update_replaces_cached_ams(self):
         """Counterpart to the #1371 fix: preservation only kicks in when the

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است