From 072b8c8ce16c0767c096084b5efe437684955373 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 16 May 2026 09:21:19 +0200 Subject: [PATCH] fix(vp): preserve AMS/vt_tray/net across incremental push_status updates (#1371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 + .../services/virtual_printer/mqtt_bridge.py | 39 +++++- backend/tests/unit/test_vp_mqtt_bridge.py | 118 ++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bec72bc..b1e047204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file. - **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean. ### Fixed +- **BambuStudio now sees AMS / vt_tray / net info from a virtual printer without requiring a printer power-cycle** ([#1371](https://github.com/maziggy/bambuddy/issues/1371), reported by @Andlar94) — Symptom on a non-proxy VP (the user's A1 in `print_queue` mode): connecting BambuStudio to the VP showed no AMS / external spool info on the Device page; the only workaround was to power the printer off and back on while BambuStudio was open, after which the info populated for one window. **Root cause** in `MQTTBridge._on_printer_raw` at [`backend/app/services/virtual_printer/mqtt_bridge.py`](backend/app/services/virtual_printer/mqtt_bridge.py): the bridge's cache of the real printer's `push_status` was `self._latest_print_state = copy.deepcopy(print_data)` — a wholesale replacement on every incoming push. Bambu firmware sends two shapes of `push_status`: full pushall responses (on `pushall` request / printer reconnect) include AMS / vt_tray / net.info / lights_report, and ~1 Hz incremental updates with just the fields that changed (temperatures, fan speeds, wifi signal). The first incremental push after a pushall therefore wiped AMS info from the bridge cache, and BambuStudio (which reads the cache via the VP's own 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, which forces Bambuddy to reconnect and re-issue `pushall`. **Fix:** in the cache update path, preserve a small set of "slicer-visible sticky" top-level keys from the previous cache when the incoming push doesn't include them: `ams`, `vt_tray`, `ams_extruder_map`, `mapping`, `net`, `ipcam`, `lights_report`. Mirrors the same preservation pattern Bambuddy itself already uses for its own internal `state.raw_data` at [`bambu_mqtt.py:2686-2711`](backend/app/services/bambu_mqtt.py); without that, even Bambuddy's own UI would have shown blank AMS in the same way after an incremental push. The new sticky-key set adds three entries (`net`, `ipcam`, `lights_report`) that the slicer specifically cares about: BambuStudio reads `net.info[*].ip` for the FTP destination IP (which the bridge then rewrites to the VP bind IP), uses `ipcam.rtsp_url` for the camera mirror, and renders `lights_report` for the chamber-light toggle. Note: the fix only covers the typical "incremental push omits the sticky key" case — if a future firmware sends a *partial* AMS list (e.g. only one unit's tray subset), that incoming partial push would still replace the cached AMS. That's a rarer scenario and would need full Bambuddy-style per-unit deep-merge; deferred until anyone hits it. **Regression tests** in [`backend/tests/unit/test_vp_mqtt_bridge.py`](backend/tests/unit/test_vp_mqtt_bridge.py): new `test_incremental_push_preserves_ams_from_previous_cache` seeds the cache with a full pushall payload (AMS + vt_tray + lights_report), fires a temps-only incremental push, and pins that all three sticky fields survive with their original values; new `test_incoming_ams_update_replaces_cached_ams` pins the counterpart — when an incoming push DOES include `ams`, the cached value is replaced, so the preservation doesn't shadow real AMS state changes. All 29 mqtt_bridge tests + 158 in the wider virtual-printer sweep pass; ruff clean. + - **Queue items no longer get permanently stuck in `printing` status when the printer was in `FINISH` state at dispatch time, and direct-dispatch (Library → Print) no longer reports false success in the same scenario** ([#1370](https://github.com/maziggy/bambuddy/issues/1370), reported by @Martinnygaard) — Symptom: queue page shows `Busy: ` even though the printer is connected, idle, and `awaiting_plate_clear=False`; no new prints will dispatch to it until the user manually deletes or reassigns the queue row. Reproducible by queueing (or directly dispatching) onto a printer that still has the un-dismissed "Print complete" prompt from a prior job. **Root cause** in `_watchdog_print_start` at [`backend/app/services/print_scheduler.py`](backend/app/services/print_scheduler.py) **and the parallel `_verify_print_response` at [`backend/app/services/background_dispatch.py`](backend/app/services/background_dispatch.py)**: the post-dispatch verifiers both treated *any* `gcode_state` transition away from `pre_state` as proof that the printer had accepted the `project_file` command. In the reporter's bundle, item 6 dispatched while printer 3 was in `FINISH` (residual from item 3 earlier that day) — firmware silently rejected the new `project_file` because the previous-print prompt was still up, and ~2 minutes later the user manually dismissed the screen prompt, putting the printer into `IDLE`. The watchdog saw `state != pre_state` and returned early as "command landed", but `FINISH → IDLE` is the user dismissing a prompt, **not** the printer accepting our project_file — so the queue row stayed at `'printing'` indefinitely and the scheduler's busy-printer seed (`SELECT printer_id FROM print_queue WHERE status='printing'` in [`print_scheduler.py:166-171`](backend/app/services/print_scheduler.py)) permanently marked printer 3 as busy. The same broad-transition bug existed in `_verify_print_response`, which would have caused direct-dispatch (Library → Print) onto a FINISH-state printer to report false success — silently failing to print while the UI showed the dispatch as complete. **Fix:** in both verifiers, narrow the "command landed" check to an allow-list of active-print states (`PREPARE` / `SLICING` / `RUNNING` / `PAUSE`) instead of "any state that isn't `pre_state`". Inactive states (`IDLE`, `FINISH`, `FAILED`) no longer short-circuit the early return. The `subtask_id`-advance signal stays as-is in both verifiers — it remains the definitive "command landed" path for H2D firmware that sits at `FINISH` for ~50 s after accepting `project_file` before transitioning to `PREPARE` (#1078 stays green in both). **Resilience hardening alongside the fix:** the watchdog's revert commit and `printer_manager._persist_awaiting_plate_clear` now run through `run_with_retry` ([`backend/app/core/database.py`](backend/app/core/database.py)), so SQLite single-writer `database is locked` contention can't silently drop the queue-row revert or the plate-clear gate flag. The revert path returns a tristate sentinel (`"reverted"` / `"already_moved_on"` / `"revert_failed"`) so the post-revert MQTT session-recovery logic only runs when we actually reverted (or the commit failed) — never when `on_print_complete` had already cleared the row, where a forced reconnect could break a healthy concurrent print on the same printer. (Most other queue/archive writes already went through `run_with_retry`; these two were the holdouts that surfaced as repeated `Failed to persist awaiting_plate_clear` warnings in the reporter's bundle.) **Manual recovery for users on 0.2.4** who already have stuck rows: stop Bambuddy, then `sqlite3 /app/data/bambuddy.db "UPDATE print_queue SET status='cancelled', completed_at=datetime('now') WHERE status='printing';"` and restart. **Regression tests** — new `test_reverts_on_finish_to_idle_user_dismissed_prompt` (queue) and `test_returns_false_on_finish_to_idle_user_dismissed_prompt` (direct-dispatch) reproduce the exact reporter scenario on both code paths; new `test_does_not_revert_on_pickup_via_active_state` (queue) and `test_returns_true_on_each_active_print_state` (direct-dispatch) iterate all four active-print states (PREPARE/SLICING/RUNNING/PAUSE) and pin that each one is correctly treated as a valid "command landed" signal. Existing `test_no_revert_if_item_already_completed` was also hardened — it now uses a real client mock and asserts `force_reconnect_stale_session.assert_not_called()`, so the tristate-sentinel guard around the recovery path is pinned (catches the regression I introduced and then fixed during the audit pass). The pre-existing `test_exits_on_state_change` (uses `RUNNING`) and `test_exits_on_subtask_id_change_even_if_state_still_finish` (the #1078 H2D path) both still pass without modification. All 29 watchdog tests across both files + 411 in the scheduler/queue/dispatch/printer-manager sweep + 3161 in the full backend unit suite + 302 in the targeted integration sweep all pass; ruff clean. - **Spool removal from AMS no longer requires a manual Reconnect on X1C printers that report `power_on_flag=False` while idle** ([#1365](https://github.com/maziggy/bambuddy/issues/1365), reported by @an3k via @maziggy) — On an X1C running firmware 01.08.02.00, pulling a spool out of an AMS slot left the slot showing as full in Bambuddy until the user clicked "Reconnect"; even re-reading the (now empty) slot's RFID from the printer screen didn't propagate. **Root cause:** the empty-slot detection in [`_handle_ams_data`](backend/app/services/bambu_mqtt.py) at [`bambu_mqtt.py:1721`](backend/app/services/bambu_mqtt.py) gated on `if tray_exist_bits_str and power_on:` — meaning *any* MQTT message with `power_on_flag=False` was skipped wholesale. That guard was added in [`488f6631`](https://github.com/maziggy/bambuddy/commit/488f6631) to fix [#765](https://github.com/maziggy/bambuddy/issues/765), where a printer's final shutdown message (all-zero `tray_exist_bits` + `power_on_flag=False`) was wiping AMS slot data and triggering auto-unlink. But this user's X1C firmware emits `power_on_flag=False` between prints with `tray_exist_bits` still reflecting the real slot inventory — so every spool-removal update was silently discarded and only the manual Reconnect (which sends `pushall`, a full per-tray snapshot independent of the bitfield path) would correct the view. **Fix:** narrow the skip to the exact shutdown pattern — zero bits **AND** `power_on_flag=False`. Non-zero `tray_exist_bits` with `power_on_flag=False` is valid idle-AMS state and the update is now applied. The original #765 regression test (`test_shutdown_message_preserves_ams_data`) uses `tray_exist_bits='0'` and therefore still passes, so the shutdown protection is preserved exactly. **New regression test** `test_idle_printer_with_power_off_and_nonzero_bits_clears_removed_slot` in [`backend/tests/unit/services/test_bambu_mqtt.py`](backend/tests/unit/services/test_bambu_mqtt.py) pins the #1365 behavior: a removal update with non-zero bits and `power_on_flag=False` clears the affected slot, with other slots untouched. All 250 bambu_mqtt tests pass; ruff clean. diff --git a/backend/app/services/virtual_printer/mqtt_bridge.py b/backend/app/services/virtual_printer/mqtt_bridge.py index da62177bc..517a2bc61 100644 --- a/backend/app/services/virtual_printer/mqtt_bridge.py +++ b/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 diff --git a/backend/tests/unit/test_vp_mqtt_bridge.py b/backend/tests/unit/test_vp_mqtt_bridge.py index c0b541903..66a20399c 100644 --- a/backend/tests/unit/test_vp_mqtt_bridge.py +++ b/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