Просмотр исходного кода

fix(vp): accumulate cached push_status per-field instead of allowlist (#1622)

  Bridge cache replaced prev state wholesale on each incremental, re-merging
  only a 14-key allowlist. Capability/lifecycle fields (cali_version,
  print_type, mc_print_stage, device, ...) drained out within one 1Hz tick,
  greying out BambuStudio's Device-tab UIs (manage-calibration, AMS-slot
  dropdown) once the cache thinned. Most P1S users miss it by timing — they
  click Device tab while the cache is still fat from the connect pushall.

  Switch to per-field accumulate matching bambu_mqtt.py's internal state
  handler: prev keys carry over verbatim when not present in the incoming
  push, new values overwrite when present. _merge_ams_dict for partial AMS
  blobs unchanged (#1387 / #1371 regression guards stay green).
  _SLICER_VISIBLE_STICKY_KEYS removed — new logic is a strict superset.
maziggy 2 месяцев назад
Родитель
Сommit
da799447f6

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 36 - 63
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -52,38 +52,18 @@ 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",
-    # Pre-flight / Prepare-tab fields that BambuStudio reads off cached
-    # push_status. Bambu firmware emits them in full pushall but typically
-    # OMITS them from 1 Hz incremental updates, so without sticky-preservation
-    # the cache drops them after the very next tick and the slicer's
-    # "block Send while busy / unknown firmware" branch kicks in. Same shape
-    # as #1228 (storage indicators) and #1558 (live-progress fields) —
-    # cached-branch field-shape parity, not a new mechanism.
-    "upgrade_state",  # Send pre-flight reads dis_state / force_upgrade
-    "xcam",  # Prepare-tab reads spaghetti / first-layer / halt sensitivity
-    "hw_switch_state",  # Hardware switch state (Prepare tab)
-    "nozzle_diameter",
-    "nozzle_type",
-    "online",  # Module online map (ahb / rfid / version)
-    "ams_status",  # AMS overall status; can be ams_status-only incremental
-)
+# Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is
+# updated per-field — each `if "X" in data: self.state.X = ...` block leaves
+# every other field untouched, so the state accumulates everything the
+# printer has ever sent. The bridge cache below mirrors that pattern: when
+# the incoming push_status omits a field, the previous value is preserved
+# verbatim; only fields actually present in the new push overwrite. This
+# stops capability/lifecycle fields (cali_version, print_type, mc_print_stage,
+# device, ...) draining out of the cache between pushalls, which surfaced
+# as #1622 (BambuStudio's Device-tab UIs greying out on P1S after the
+# cache drained to a thin incremental snapshot). The `ams` field still
+# gets unit-/tray-level deep merge via `_merge_ams_dict` because firmware
+# sends partial `ams` blobs under the same key (#1387).
 
 
 def _ip_to_uint32_le(ip_str: str) -> int:
@@ -602,39 +582,32 @@ class MQTTBridge:
             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).
+            # include the full top-level field set (AMS, vt_tray, net,
+            # cali_version, print_type, mc_print_stage, device, ...) — and
+            # ~1 Hz incrementals with just the fields that changed (temps,
+            # fan, wifi, status). Carry over every prev field the incoming
+            # push doesn't overwrite, mirroring the per-field accumulate
+            # pattern in bambu_mqtt.py's internal state handler — without
+            # this the cache thins out to whatever the latest incremental
+            # carried (~17 keys on P1S in #1622), and the slicer's Device-
+            # tab capability gates (manage-calibration, AMS-assign dropdown,
+            # …) flip off because their gating fields drained from the
+            # cache. The deep-copy is defensive: without it the carried-
+            # over nested dicts/lists are shared with the previous cache,
+            # so any in-place mutation later would corrupt both.
             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:
-                        if sticky_key in prev:
-                            # Defensive deep copy — without this the carried-over
-                            # nested dicts/lists are shared between new_state and
-                            # the previous cache, so any in-place mutation later
-                            # (current or future code paths) would corrupt both.
-                            new_state[sticky_key] = copy.deepcopy(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"])
+                for prev_key, prev_value in prev.items():
+                    if prev_key not in new_state:
+                        new_state[prev_key] = copy.deepcopy(prev_value)
+                # Firmware sends partial `ams` blobs (status-only / unit-
+                # targeted / tray-targeted) under the same key on
+                # incremental updates, which would overwrite the cached
+                # full blob and break the slicer's AMS render (#1387 /
+                # #1371). Deep-merge mirrors what bambu_mqtt.py does
+                # internally in `_handle_ams_data`.
+                if 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
             dump_wire(self.vp_name, "in", new_state)
             return

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

@@ -402,6 +402,73 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_incremental_push_preserves_non_allowlisted_capability_fields(self):
+        """Regression for #1622: BambuStudio gates Device-tab UIs (manage
+        calibration, AMS-slot filament dropdown, ...) on capability /
+        lifecycle fields (cali_version, print_type, mc_print_stage,
+        device, ...) it reads off the cached push_status. Before the fix
+        these fields were not in the allowlist and drained out of the
+        bridge cache on the first 1 Hz incremental tick, so the slicer's
+        Device tab would grey out the gated UIs once the cache thinned.
+        After the fix the cache accumulates everything the printer has
+        ever sent, dropped only when explicitly overwritten.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        full_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "cali_version": 2,
+                    "print_type": "idle",
+                    "gcode_state": "IDLE",
+                    "mc_print_stage": "0",
+                    "mc_stage": 0,
+                    "device": {"ext_tool": {"info": []}},
+                    "cfg": "",
+                    "home_flag": 256,
+                    "wifi_signal": "-50dBm",
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
+        await asyncio.sleep(0.01)
+
+        # Incremental push carrying only temps + wifi — none of the
+        # capability/lifecycle fields above are mentioned.
+        incremental_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "wifi_signal": "-55dBm",
+                    "nozzle_temper": 24.5,
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        # Incremental values applied.
+        assert cached["wifi_signal"] == "-55dBm"
+        assert cached["nozzle_temper"] == 24.5
+        # Capability / lifecycle fields preserved from the prior pushall
+        # — the symptoms in #1622 (Device-tab UIs disabled) trace to these
+        # exact keys missing.
+        assert cached["cali_version"] == 2
+        assert cached["print_type"] == "idle"
+        assert cached["gcode_state"] == "IDLE"
+        assert cached["mc_print_stage"] == "0"
+        assert cached["mc_stage"] == 0
+        assert cached["device"] == {"ext_tool": {"info": []}}
+        assert cached["cfg"] == ""
+        assert cached["home_flag"] == 256
+
+        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

Некоторые файлы не были показаны из-за большого количества измененных файлов