Browse Source

fix(vp): preserve AMS/vt_tray/net across incremental push_status updates (#1371)

  The bridge cache replaced _latest_print_state wholesale on every
  push_status arrival. Bambu firmware sends full pushall responses
  (with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
  requests, but ~1 Hz incremental updates with only the fields that
  changed. The first incremental push after a pushall therefore wiped
  AMS info from the bridge cache, and slicers reading the cache (via
  the VP's 1 Hz status push) saw a stripped-down state with no AMS
  visible until the next pushall — typically only on a manual printer
  power-cycle.

  Preserve a small set of slicer-visible sticky keys from the previous
  cache when the incoming push doesn't carry them: ams, vt_tray,
  ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
  same pattern Bambuddy uses for its own internal state.raw_data.
maziggy 1 week ago
parent
commit
072b8c8ce1

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 38 - 1
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -48,6 +48,25 @@ logger = logging.getLogger(__name__)
 
 REFRESH_INTERVAL_SECONDS = 30.0
 
+# Top-level push_status fields that Bambu firmware sends in FULL pushall
+# responses (on `pushall` request / printer reconnect) but typically OMITS
+# from 1 Hz incremental push_status updates. Without preserving these
+# fields across incremental updates, the bridge cache would lose AMS info
+# (and friends) between pushalls — slicers reading the cache would see a
+# stripped-down state and the fix would only re-appear on a manual printer
+# power-cycle (#1371). Mirrors the same set Bambuddy itself preserves in
+# bambu_mqtt.py:2686-2711 for its own internal raw_data, with a few more
+# entries that the slicer cares about (net, ipcam, lights_report).
+_SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = (
+    "ams",
+    "vt_tray",
+    "ams_extruder_map",
+    "mapping",
+    "net",
+    "ipcam",
+    "lights_report",
+)
+
 
 def _ip_to_uint32_le(ip_str: str) -> int:
     """Encode dotted-quad IPv4 as little-endian uint32 (Bambu MQTT's `net.info[].ip` shape)."""
@@ -261,7 +280,25 @@ class MQTTBridge:
                                 entry["ip"] = self._vp_ip_uint32_le
             # Defensive deep copy on store so the cache is fully decoupled from
             # the freshly-parsed tree and from any reader's reference.
-            self._latest_print_state = copy.deepcopy(print_data)
+            new_state = copy.deepcopy(print_data)
+            # Bambu firmware sends two kinds of push_status: full pushall
+            # responses (on `pushall` requests / printer reconnect) which
+            # include AMS, vt_tray, net, etc. — and ~1 Hz incremental
+            # updates with just the fields that changed (typically temps,
+            # fan, wifi). Without preserving sticky fields from the previous
+            # cache, the first incremental push after a pushall would wipe
+            # AMS info from the bridge cache, and slicers reading the cache
+            # between pushalls would see a stripped-down printer state with
+            # no AMS visible until the next pushall — typically only when
+            # the user power-cycles the printer (#1371). Mirror the same
+            # preservation pattern Bambuddy uses for its own internal state
+            # in bambu_mqtt.py (see _SLICER_VISIBLE_STICKY_KEYS below).
+            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]
+            self._latest_print_state = new_state
             return
 
         # info.get_version responses → cache the module list so the synthetic

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

@@ -233,6 +233,124 @@ class TestPushStatusCache:
         assert bridge.get_latest_print_state() is None
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_incremental_push_preserves_ams_from_previous_cache(self):
+        """Regression for #1371: Bambu firmware sends FULL push_status on
+        pushall (with AMS/vt_tray/net/etc.) but typically OMITS those fields
+        from 1 Hz incremental push_status updates. Without preserving the
+        sticky keys across pushes, the cache forgets AMS info after the first
+        incremental update, and BambuStudio (which reads the cache via the
+        VP's 1 Hz status push) sees no AMS info until the user power-cycles
+        the printer (forcing a fresh pushall).
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Initial pushall response with full state, AMS included.
+        full_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "gcode_state": "IDLE",
+                    "wifi_signal": "-50dBm",
+                    "ams": {
+                        "ams": [
+                            {
+                                "id": "0",
+                                "tray": [
+                                    {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
+                                    {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
+                                ],
+                            }
+                        ],
+                        "tray_exist_bits": "3",
+                    },
+                    "vt_tray": {"id": "254", "tray_type": ""},
+                    "lights_report": [{"node": "chamber_light", "mode": "on"}],
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        assert cached["vt_tray"]["id"] == "254"
+        assert cached["lights_report"][0]["mode"] == "on"
+
+        # 2. Incremental push with only temp/wifi changes — NO ams field.
+        # This is what the printer sends every ~1 s between full pushalls.
+        incremental_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "wifi_signal": "-55dBm",
+                    "chamber_temper": 26.0,
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        # New fields take effect.
+        assert cached["wifi_signal"] == "-55dBm"
+        assert cached["chamber_temper"] == 26.0
+        # Sticky fields preserved from the previous cache (the #1371 fix).
+        assert "ams" in cached, "AMS field must be preserved across incremental pushes (#1371)"
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        assert cached["ams"]["tray_exist_bits"] == "3"
+        assert cached["vt_tray"]["id"] == "254"
+        assert cached["lights_report"][0]["mode"] == "on"
+
+        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
+        incoming push OMITS a sticky key. When the printer DOES send a fresh
+        `ams` value (e.g. on a pushall, or when AMS state genuinely changes),
+        that value must take effect — the preservation must not shadow real
+        updates.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Initial state: PLA in tray 0.
+        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"}]}]},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        # 2. Fresh push with PETG — must replace, not get shadowed by the old PLA.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PETG"}]}]},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PETG"
+
+        await bridge.stop()
+
 
 # ---------------------------------------------------------------------------
 # Caching: get_version response

Some files were not shown because too many files changed in this diff