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.
This commit is contained in:
maziggy
2026-05-16 09:21:19 +02:00
parent 5680f5d34b
commit 072b8c8ce1
3 changed files with 158 additions and 1 deletions
+2
View File
File diff suppressed because one or more lines are too long
@@ -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
View File
@@ -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