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.
This commit is contained in:
maziggy
2026-06-11 17:23:49 +02:00
parent 282aefc564
commit da799447f6
3 changed files with 106 additions and 64 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
@@ -52,38 +52,18 @@ logger = logging.getLogger(__name__)
REFRESH_INTERVAL_SECONDS = 30.0 REFRESH_INTERVAL_SECONDS = 30.0
# Top-level push_status fields that Bambu firmware sends in FULL pushall # Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is
# responses (on `pushall` request / printer reconnect) but typically OMITS # updated per-field — each `if "X" in data: self.state.X = ...` block leaves
# from 1 Hz incremental push_status updates. Without preserving these # every other field untouched, so the state accumulates everything the
# fields across incremental updates, the bridge cache would lose AMS info # printer has ever sent. The bridge cache below mirrors that pattern: when
# (and friends) between pushalls — slicers reading the cache would see a # the incoming push_status omits a field, the previous value is preserved
# stripped-down state and the fix would only re-appear on a manual printer # verbatim; only fields actually present in the new push overwrite. This
# power-cycle (#1371). Mirrors the same set Bambuddy itself preserves in # stops capability/lifecycle fields (cali_version, print_type, mc_print_stage,
# bambu_mqtt.py:2686-2711 for its own internal raw_data, with a few more # device, ...) draining out of the cache between pushalls, which surfaced
# entries that the slicer cares about (net, ipcam, lights_report). # as #1622 (BambuStudio's Device-tab UIs greying out on P1S after the
_SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = ( # cache drained to a thin incremental snapshot). The `ams` field still
"ams", # gets unit-/tray-level deep merge via `_merge_ams_dict` because firmware
"vt_tray", # sends partial `ams` blobs under the same key (#1387).
"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
)
def _ip_to_uint32_le(ip_str: str) -> int: def _ip_to_uint32_le(ip_str: str) -> int:
@@ -602,39 +582,32 @@ class MQTTBridge:
new_state = copy.deepcopy(print_data) new_state = copy.deepcopy(print_data)
# Bambu firmware sends two kinds of push_status: full pushall # Bambu firmware sends two kinds of push_status: full pushall
# responses (on `pushall` requests / printer reconnect) which # responses (on `pushall` requests / printer reconnect) which
# include AMS, vt_tray, net, etc. — and ~1 Hz incremental # include the full top-level field set (AMS, vt_tray, net,
# updates with just the fields that changed (typically temps, # cali_version, print_type, mc_print_stage, device, ...) — and
# fan, wifi). Without preserving sticky fields from the previous # ~1 Hz incrementals with just the fields that changed (temps,
# cache, the first incremental push after a pushall would wipe # fan, wifi, status). Carry over every prev field the incoming
# AMS info from the bridge cache, and slicers reading the cache # push doesn't overwrite, mirroring the per-field accumulate
# between pushalls would see a stripped-down printer state with # pattern in bambu_mqtt.py's internal state handler — without
# no AMS visible until the next pushall — typically only when # this the cache thins out to whatever the latest incremental
# the user power-cycles the printer (#1371). Mirror the same # carried (~17 keys on P1S in #1622), and the slicer's Device-
# preservation pattern Bambuddy uses for its own internal state # tab capability gates (manage-calibration, AMS-assign dropdown,
# in bambu_mqtt.py (see _SLICER_VISIBLE_STICKY_KEYS below). # …) 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 prev = self._latest_print_state
if prev is not None: if prev is not None:
for sticky_key in _SLICER_VISIBLE_STICKY_KEYS: for prev_key, prev_value in prev.items():
if sticky_key not in new_state: if prev_key not in new_state:
if sticky_key in prev: new_state[prev_key] = copy.deepcopy(prev_value)
# Defensive deep copy — without this the carried-over # Firmware sends partial `ams` blobs (status-only / unit-
# nested dicts/lists are shared between new_state and # targeted / tray-targeted) under the same key on
# the previous cache, so any in-place mutation later # incremental updates, which would overwrite the cached
# (current or future code paths) would corrupt both. # full blob and break the slicer's AMS render (#1387 /
new_state[sticky_key] = copy.deepcopy(prev[sticky_key]) # #1371). Deep-merge mirrors what bambu_mqtt.py does
continue # internally in `_handle_ams_data`.
# Key IS in new_state — but firmware sends partial blobs if isinstance(new_state.get("ams"), dict) and isinstance(prev.get("ams"), dict):
# (status-only / tray-targeted) under the same key on new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
# 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 self._latest_print_state = new_state
dump_wire(self.vp_name, "in", new_state) dump_wire(self.vp_name, "in", new_state)
return return
+67
View File
@@ -402,6 +402,73 @@ class TestPushStatusCache:
await bridge.stop() 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 @pytest.mark.asyncio
async def test_partial_ams_status_update_preserves_unit_list(self): async def test_partial_ams_status_update_preserves_unit_list(self):
"""#1387: Bambu firmware also sends `ams` updates where the key is """#1387: Bambu firmware also sends `ams` updates where the key is