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

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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