fix(vp): apply tray_exist_bits empty-slot cleanup to slicer-facing cache (#1726)

VP bridges bound to a target printer (Proxy mode, Queue mode with
  specific target) forwarded the printer's raw AMS push_status to the
  slicer untouched. bambu_mqtt.py::_handle_ams_data applies a
  tray_exist_bits-driven cleanup to Bambuddy's internal state
  (promote empty slots to state=9, wipe stale tray_type / tray_color /
  tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders
  empty slots as Empty, but the VP bridge cache never ran the same
  cleanup. Net result on real hardware: a printer with 3 loaded
  filaments and several previously-loaded-now-empty slots had Bambuddy's
  AMS card render those slots correctly as Empty, but BambuStudio after
  Sync painted them as phantom loaded filaments with stale color and
  material from before the slot went empty.

  Root cause: two consumers of the same payload, only one wired to the
  cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py::
  _on_printer_raw merged the ams blob via _merge_ams_dict but copied
  tray_exist_bits through as an opaque scalar without acting on it.

  Fix: factored the bit-clear logic out of _handle_ams_data into a
  module-level helper apply_tray_exist_bits(units, tray_exist_bits_str,
  *, power_on_flag, log_label). Internal path replaced with a single
  call. Bridge calls it after _merge_ams_dict on the merged ams dict,
  before the merged state is stored as the 1 Hz cached-as-base source.

  Shared shutdown guard kept on both sides: all-zero bits +
  power_on_flag=False is the printer-off pattern (#765, would
  propagate phantom empties on every reconnect); nonzero bits +
  power-off is valid idle-printer state (#1365, X1C between prints)
  and still applies. AMS-HT units (id >= 128) skipped on both sides.

  Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper
  contract directly. 3 new bridge regression tests reproduce the
  #1726 wire shape, the shutdown guard, and the AMS-HT skip on the
  cached slicer-facing state. Existing internal-state tests for the
  bit-clear logic (covers state=9 promotion, loaded-slot preserve,
  genuine-removal-with-power-on) continue to pass against the
  refactored path.

  One pre-existing bridge fixture had an inconsistent tray_exist_bits
  ('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The
  shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match
  real-printer wire shape.

  Reported by @needo37 with full code-level analysis including the
  suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to
  verify on a live system.
This commit is contained in:
maziggy
2026-06-13 08:42:59 +02:00
parent ea75788abc
commit 8a63fcbf57
5 changed files with 446 additions and 68 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Fixed
- **Virtual Printer: empty AMS slots forwarded as phantom loaded filaments to BambuStudio Sync (#1726, reported with full code-level analysis by @needo37)** — On any VP bound to a target printer (Proxy mode, or Queue mode with a specific target), the slicer-facing AMS state was the printer's raw push_status — the empty-slot cleanup that `bambu_mqtt.py::_handle_ams_data` applies to Bambuddy's own internal state was NEVER run on the bridge cache. Concrete case: real printer has 3 filaments loaded (AMS-A slots 2/3/4), AMS-A slot 1 and all of AMS-B empty; Bambuddy's AMS card renders the empty slots correctly as Empty (control — internal state path is fine), but BambuStudio after Sync paints 7 populated/green-checked filament slots — the 3 real ones plus 4 phantoms whose color/material is stale RFID/calibration data from before those slots went empty. The diagnostic signature is the mismatch between the AMS card (correct) and the slicer view (wrong) for the same payload. Archive mode and Queue-by-model are NOT affected — no target printer → no bridge → the slicer gets the synthetic stub at `mqtt_server.py:927` with no real AMS data. **Root cause:** two code paths consume the same printer AMS payload. Internal (`bambu_mqtt.py::_handle_ams_data` lines 1802-1858) parses `tray_exist_bits`, promotes empty slots to `state=9`, and wipes the stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` fields. VP bridge (`mqtt_bridge.py::_on_printer_raw` lines 551-656) deep-merges AMS structurally via `_merge_ams_dict` and copies `tray_exist_bits` through as an opaque top-level scalar — but never applies the bit→clear-empty-slot logic. The cached state ships to the slicer untouched. **Fix:** factored the bit-clear logic out of `_handle_ams_data` into a shared module-level helper `bambu_mqtt.py::apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label)` and call it from both paths. The internal call site is replaced with a single helper invocation; the bridge calls it on the merged AMS dict after `_merge_ams_dict` runs, before the 1 Hz cached-as-base push picks the cache up. Shared shutdown guard preserved on both sides: all-zero bits + `power_on_flag=False` is the printer-off pattern (#765) and skips cleanup — a non-zero bits + power-off combo is valid idle-printer state (#1365 — X1C between prints) and still applies. AMS-HT units (`id >= 128`) skipped on both sides (separate addressing scheme). **Tests:** new `TestApplyTrayExistBitsHelper` class in `test_bambu_mqtt.py` (10 cases pinning the helper contract directly — missing/unparseable bits → no-op, shutdown guard, nonzero+power-off X1C case, int-9 state, AMS-HT skip, string id handling, multi-AMS global bit math, state-promote-even-without-stale-data). 3 new bridge regression tests in `test_vp_mqtt_bridge.py::TestPushStatusCache`: `test_tray_exist_bits_clears_empty_slots_in_slicer_cache` reproduces the #1726 wire shape (slot 0 carries stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` + `tray_exist_bits="e"` → slot 0 must clear, slots 1-3 preserved), `test_tray_exist_bits_shutdown_guard_preserves_cache` pins the printer-off path won't propagate phantom empties on every reconnect, `test_tray_exist_bits_skips_ams_ht_units` pins the HT addressing skip. Existing internal-state tests for the bit-clear logic (`test_tray_exist_bits_clears_empty_slots`, `test_tray_exist_bits_promotes_empty_slot_to_state_9`, `test_tray_exist_bits_does_not_change_state_on_loaded_slots`, …) continue to pass against the refactored internal path — same contract, same behavior, different implementation seam. One pre-existing bridge fixture (`test_partial_ams_unit_update_preserves_other_units`) had an inconsistent `tray_exist_bits="3"` for two AMS units (bit 0 set, bit 4 unset, but both unit 0 and unit 1 had slot 0 populated as loaded). The fix exposed the inconsistency — corrected to `"11"` (bits 0 + 4) to match what the real printer would send. Full backend suite 5955/5955 green; ruff clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend untouched. **Verification on a live system (per @needo37's analysis):** set `BAMBUDDY_VP_DUMP_WIRE=1`, restart, Sync the slicer, inspect `<log_dir>/vp_wire/<vp>_out.json`. For any tray whose bit in `tray_exist_bits` is 0, `tray_type`/`tray_color` should now be empty.
- **Windows: `/api/local-backup/status` 500 on `ZoneInfoNotFoundError: 'No time zone found with key UTC'` (from a user's log on the Windows installer)** — Reported via a Windows traceback against the new local-backup status endpoint. The stdlib `zoneinfo` module reads the system IANA tz database on Linux/macOS, but Windows has none — and the embedded Python in our Windows installer doesn't carry the `tzdata` PyPI package either, so even `ZoneInfo("UTC")` raises `ZoneInfoNotFoundError`. `_local_zone()` in `services/local_backup.py` only caught that exception for the `TZ`-env branch; the empty-`TZ` fallback and the unrecognised-`TZ` fallback both unconditionally called `ZoneInfo("UTC")` and re-raised, bubbling out of the FastAPI handler as a 500. **Fix (two parts):** (1) `_local_zone()` is now resilient — return type widened from `ZoneInfo` to `tzinfo`, the `UTC` fallback is wrapped in its own try, and the last-resort fallback returns the stdlib `datetime.timezone.utc` (which needs no IANA DB and satisfies every `astimezone` / `str()` call site downstream — `str(timezone.utc) == "UTC"` matches the previous response shape). Restores function on existing Windows installs without re-bundling. (2) `requirements.txt` now pins `tzdata>=2024.1; sys_platform == "win32"` so the next Windows installer build ships the IANA DB and any non-UTC `TZ` value (e.g. `Europe/Berlin`) resolves correctly — the stdlib fallback can only ever give UTC. Linux/macOS unaffected: the platform marker keeps them on the system tz DB they already have. **Tests:** new `test_zoneinfo_completely_unavailable_falls_back_to_stdlib_utc` in `test_local_backup.py` monkeypatches `ZoneInfo` to always raise `ZoneInfoNotFoundError` and pins that `_local_zone()` returns `datetime.timezone.utc` rather than propagating. 31/31 local_backup tests green; ruff clean.
- **Print-modal "off" toggles for `flow_cali` and `nozzle_offset_cali` now actually suppress the calibration stage (live-tested on H2D 01.x)** — The Re-print / Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted the user's "off" choice and flowed it correctly through to the `project_file` MQTT publish — Bambuddy sent `extrude_cali_flag: 2` and `nozzle_offset_cali: 2` per our reading of "1 = run, 2 = skip" inherited from the #1478 / #1682 work. Live test on an H2D running firmware 01.x: with both toggles off in Bambuddy's modal, the printer's `stg` queue (the pre-print stage list firmware publishes via push_status) still included stage **8** ("Calibrating dynamic flow") and stage **39** ("Nozzle offset calibration") — and physically ran them at print start. The `2` value did NOT suppress the stage despite our earlier "skip and reuse stored PA" reading. **Root cause:** the encoding for the "off" wire value is `0`, not `2`. The `2` value appears to mean "skip the explicit calibration pass but still apply / verify the stored PA value via the calibration stage" — close to a no-op in terms of K-factor but the printer still queues the stage and runs the per-print physical sequence. `0` is what actually drops the stage from the `stg` queue. A real BambuStudio Send-dialog capture on the same firmware (proxy-mode VP echo) also showed `0` for both fields when calibrations are unchecked, contradicting the #1478 commit message which read `0` as "never sent by BambuStudio." **Fix:** `bambu_mqtt.py::start_print` — `extrude_cali_flag` is now `1 if flow_cali else 0` (was `2`), and `nozzle_offset_cali` is `1 if (nozzle_offset_cali and is_dual_nozzle) else 0` (was `2`). The dual-nozzle gate stays — single-nozzle prints continue to force-skip the nozzle-offset calibration their head doesn't support (#1682). `1` (run) is unchanged on both fields. **Verification:** live re-test on the same H2D with both toggles still off — `stg: [29, 13, 4, 14, 3]` (cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 dropped out cleanly. **What's NOT fixed:** `vibration_cali` is a JSON `false` bool in both Bambuddy's and BambuStudio's wire format, and the H2D firmware queues stage **3** ("Vibration compensation") regardless of the bool value — this is firmware-side and not solvable at our dispatch layer with the current field. Captured as a follow-up to investigate whether a parallel `vibration_cali_flag` integer field exists. **Tests:** `test_bambu_mqtt.py` — `test_p2s_uses_boolean_format` flipped `extrude_cali_flag == 2` → `== 0`; `test_nozzle_offset_cali_default_is_skip`, `test_nozzle_offset_cali_ignored_on_single_nozzle`, `test_nozzle_offset_cali_false_on_dual_nozzle` flipped `== 2` → `== 0`; docstrings updated to reflect the #1721 finding. The `1 if user_wants` branch in both tests for the "on" case is unchanged. 281/281 bambu_mqtt tests green; full backend suite 5941/5941 green with `-n 30`; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`).
- **Support-bundle log noise: VP bridge nudge + SD-card cleanup (#1721 adjacent, observed on reporter's A1)** — Two warnings polluting every A1 support bundle on a healthy print. Neither was the cause of #1721's timelapse complaint — both are adjacent noise. **(1) `request_status_update: not connected`** — `mqtt_bridge.py::_resolve_client` calls `_request_version` + `request_status_update` immediately after attaching a raw-message handler so the bridge cache populates without waiting for the next periodic pushall. The bind frequently races the real printer's MQTT TLS handshake — a slicer-side reconnect re-resolves the client before the underlying session has reconnected, especially on A1 firmware which reconnects more aggressively than X1/H2/P. `request_status_update` logs `[serial] request_status_update: not connected` at WARNING on the not-connected return path. The nudge is a best-effort optimisation; the fall-through (next periodic pushall) populates the cache anyway, so the WARNING fires on routine, expected, recoverable state. **Fix:** gate both nudges on `current.state.connected` at the bind site. When the client comes up, the next `_resolve_client` tick re-enters this branch on identity change OR the periodic pushall in `bambu_mqtt.py` fills the cache — same end state, no benign WARNING. The WARNING in `bambu_mqtt.py:3224` is unchanged: it's still a real signal for the other callers (`/printers/{id}/refresh-status` user API, bug-reporter helper) where "you asked for a refresh on a dead client" is genuinely worth logging. New `test_post_bind_nudge_skipped_when_target_not_connected` in `test_vp_mqtt_bridge.py::TestBridgeLifecycle` pins the contract. **(2) `SD card cleanup failed after 3 attempts ... (file may linger on SD card)`** — The post-finish helper in `main.py` deletes the uploaded file from the printer's SD card to prevent the ghost-print-on-power-cycle behaviour (#374, #1542). It tries up to three candidate paths (`derive_remote_filename(archive.filename)`, then `{subtask_name}.3mf`, then `{subtask_name}.gcode`), each up to 3 times with 2 s backoff, then logs WARNING if all fail. `delete_file_async` returned `bool` — `True` for success, `False` for ANYTHING else (FTP 550 file-not-found, network error, auth fail, transient FTP error). The A1 firmware (and most other Bambu firmwares post-print) cleans the SD-card upload itself before our cleanup runs, every candidate FTP-DELE returns 550, all three retries × three candidates × 2 s sleeps fire, then WARNING. That WARNING shouldn't exist on a healthy print where the printer self-cleaned. The same shape exists in `_cleanup_forced_timelapse` (#1397) walking the four timelapse dirs. **Fix:** `bambu_ftp.py` now exports a `DeleteResult` enum (`DELETED` / `NOT_FOUND` / `FAILED`). `BambuFTPClient.delete_file` detects the 550 case via `isinstance(e, ftplib.error_perm) and str(e).startswith("550")` (same pattern already used in the download path for the symmetric `FileNotOnPrinterError` sentinel from #972). `delete_file_async` now returns `DeleteResult`. Both post-finish cleanup helpers (`main.py::on_print_finished` SD branch + `_cleanup_forced_timelapse`) only WARN when at least one candidate returned `FAILED`; an all-`NOT_FOUND` outcome logs DEBUG ("nothing to delete — printer likely self-cleaned"). The cleanup helper also no longer burns the 2 s × 3 retry budget on a `NOT_FOUND` result (550 will never recover by waiting); only `FAILED` triggers backoff. `DELETE /printers/{id}/files/...` returns 404 (not 500) on `NOT_FOUND`, more accurate for the user-facing UI. Three other production callers (`print_scheduler` pre-upload delete, two `background_dispatch` fire-and-forget cleanups) are unchanged at the call site — they discard the return value. **Tests:** `test_delete_file` and `test_delete_file_async` in `test_bambu_ftp.py` switched to the enum (3 cases each). 2 new regression tests in `test_cleanup_forced_timelapse.py`: `test_forced_no_warning_when_every_dir_returns_not_found` pins the #1721 path (every candidate dir → 550 → no WARNING, one DEBUG summary), `test_forced_warns_when_any_dir_returns_failed` pins the counterpart (any FAILED keeps the WARNING — that's the signal the maintainer wants). `caplog` asserts the log record's level + content directly. Full backend suite 5941/5941 green; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). No migration, no new i18n keys, no schema changes.
+113 -67
View File
@@ -31,6 +31,108 @@ logger = logging.getLogger(__name__)
_AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
def apply_tray_exist_bits(
units: list,
tray_exist_bits_str: str | int | None,
*,
power_on_flag: bool = True,
log_label: str | None = None,
) -> int:
"""Wipe stale per-tray filament fields on slots whose `tray_exist_bits` bit is 0.
`tray_exist_bits` is firmware's canonical "which slots have a spool" bitmask
(BambuStudio uses it too). For every slot whose bit is 0, promote the tray
`state` to 9 (firmware's "no spool" code) and clear `tray_type` / `tray_color`
/ `tray_info_idx` / `tag_uid` / `tray_uuid` / `remain` etc so downstream
readers (Bambuddy's AMS card, the VP slicer-facing cache, inventory short-
circuits keyed on `state in {9, 10}`) all see one canonical empty-slot signal
instead of guessing from payload shape (#1322, #147).
Two callers share this helper to keep their views consistent:
1. ``_handle_ams_data`` for Bambuddy's internal AMS state (printer card).
2. ``virtual_printer.mqtt_bridge._on_printer_raw`` for the cached slicer-
facing push_status (#1726 — without this the VP would forward stale
per-tray fields for empty slots, and BambuStudio's Sync would render
phantom loaded slots).
Skipped only on the printer-shutdown pattern: all-zero bits paired with
``power_on_flag=False`` (#765). Non-zero bits with ``power_on_flag=False``
is valid idle-printer state (#1365 — X1C between prints) and MUST be applied
so spool removal is detected without requiring a manual reconnect.
AMS-HT units (``id >= 128``) use a separate addressing scheme and are
skipped here.
`tray_exist_bits_str` is expected as a hex string (firmware sends it that
way). Ints are tolerated for defensive symmetry but typically not seen
on the wire. ``None`` / empty / unparseable → no-op.
Mutates ``units`` in place. Returns the number of slots cleared.
"""
if not tray_exist_bits_str:
return 0
try:
if isinstance(tray_exist_bits_str, int):
tray_exist_bits = tray_exist_bits_str
else:
tray_exist_bits = int(tray_exist_bits_str, 16)
except (ValueError, TypeError):
return 0
if tray_exist_bits == 0 and not power_on_flag:
return 0
if not isinstance(units, list):
return 0
cleared = 0
for ams_unit in units:
if not isinstance(ams_unit, dict):
continue
ams_id_raw = ams_unit.get("id")
if ams_id_raw is None:
continue
try:
ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
except (ValueError, TypeError):
continue
if not isinstance(ams_id, int) or ams_id >= 128:
# Skip AMS-HT (id >= 128) — separate addressing scheme.
continue
for tray in ams_unit.get("tray", []):
if not isinstance(tray, dict):
continue
tray_id_raw = tray.get("id")
if tray_id_raw is None:
continue
try:
tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
except (ValueError, TypeError):
continue
if not isinstance(tray_id, int):
continue
global_bit = ams_id * 4 + tray_id
slot_exists = (tray_exist_bits >> global_bit) & 1
if slot_exists:
continue
tray["state"] = 9
if tray.get("tray_type"):
if log_label:
logger.debug(
f"[{log_label}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
f"(tray_exist_bits bit {global_bit} = 0)"
)
tray["tray_type"] = ""
tray["tray_sub_brands"] = ""
tray["tray_color"] = ""
tray["tray_id_name"] = ""
tray["tag_uid"] = "0000000000000000"
tray["tray_uuid"] = "00000000000000000000000000000000"
tray["tray_info_idx"] = ""
tray["remain"] = 0
cleared += 1
return cleared
@dataclass
class MQTTLogEntry:
"""Log entry for MQTT message debugging."""
@@ -1789,73 +1891,17 @@ class BambuMQTTClient:
# Convert back to list, sorted by ID for consistent ordering
merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
# Check tray_exist_bits to clear empty slots (Issue #147)
# New AMS models don't send empty tray data - they just update tray_exist_bits
# Each bit in tray_exist_bits represents a slot: bit=0 means empty, bit=1 means has spool
# Skip ONLY the printer-shutdown pattern: all-zero bits paired with
# power_on_flag=False (#765). On shutdown that combination would wipe all
# slot data and cause auto-unlink to remove spool assignments. Non-zero
# bits with power_on_flag=False are valid AMS state from an idle printer
# (#1365 — X1C reports power_on_flag=False between prints while the AMS
# keeps reporting its actual slot inventory); the update MUST be applied
# so spool removal is detected without requiring a manual reconnect.
tray_exist_bits_str = ams_data.get("tray_exist_bits") if isinstance(ams_data, dict) else None
power_on = ams_data.get("power_on_flag", True) if isinstance(ams_data, dict) else True
if tray_exist_bits_str:
try:
tray_exist_bits = int(tray_exist_bits_str, 16)
except (ValueError, TypeError) as e:
logger.debug("[%s] Could not parse tray_exist_bits: %s", self.serial_number, e)
tray_exist_bits = None
if tray_exist_bits is not None and not (tray_exist_bits == 0 and not power_on):
for ams_unit in merged_ams:
ams_id_raw = ams_unit.get("id")
if ams_id_raw is None:
continue
# Convert to int (may be string from JSON)
ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
if ams_id >= 128: # Skip HT AMS (id >= 128)
continue
# Bits for this AMS unit: bits (ams_id*4) to (ams_id*4 + 3)
for tray in ams_unit.get("tray", []):
tray_id_raw = tray.get("id")
if tray_id_raw is None:
continue
# Convert to int (may be string from JSON)
tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
global_bit = ams_id * 4 + tray_id
slot_exists = (tray_exist_bits >> global_bit) & 1
if not slot_exists:
# #1322 follow-up (by @RosdasHH): the bitmask is
# BambuStudio's canonical "no spool" signal, and
# works across every firmware variant (P1S, A1
# Mini, post-restart, post-Reset-Slot, steady-
# state). Promote to state=9 (firmware's
# explicit "no spool" code) so downstream
# readers — printers.py's API serializer,
# inventory.py's `tray_state in {9, 10}`
# short-circuit, the AMS card — see one
# canonical signal instead of guessing from
# payload shape. Int (not "9") to match the
# downstream `==` comparison.
tray["state"] = 9
if tray.get("tray_type"):
# Stale data from before the slot went empty
# — clear it so the AMS view doesn't render a
# colour/material that's no longer there.
logger.debug(
f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
f"(tray_exist_bits bit {global_bit} = 0)"
)
tray["tray_type"] = ""
tray["tray_sub_brands"] = ""
tray["tray_color"] = ""
tray["tray_id_name"] = ""
tray["tag_uid"] = "0000000000000000"
tray["tray_uuid"] = "00000000000000000000000000000000"
tray["tray_info_idx"] = ""
tray["remain"] = 0
# Empty-slot cleanup via tray_exist_bits (#147, #1322, #765, #1365).
# Shared with the VP bridge cache so the slicer-facing view stays in
# sync with Bambuddy's AMS card (#1726). See the helper's docstring
# for the full rationale and the printer-shutdown guard.
if isinstance(ams_data, dict):
apply_tray_exist_bits(
merged_ams,
ams_data.get("tray_exist_bits"),
power_on_flag=ams_data.get("power_on_flag", True),
log_label=self.serial_number,
)
self.state.raw_data["ams"] = merged_ams
@@ -41,6 +41,7 @@ import logging
import socket
from typing import TYPE_CHECKING
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
from backend.app.services.virtual_printer._debug import append_event, dump_wire
if TYPE_CHECKING:
@@ -651,6 +652,22 @@ class MQTTBridge:
merged = dict(prev_value)
merged.update(new_value)
new_state[key] = merged
# Apply empty-slot cleanup on the merged AMS so the slicer-facing
# cache mirrors what Bambuddy's AMS card shows internally. Without
# this the cached units carry stale per-tray filament fields for
# slots whose `tray_exist_bits` bit is 0, and BambuStudio's Sync
# paints those empty slots as phantom loaded filaments (#1726).
# Runs whether or not a prev cache existed — fresh pushalls also
# carry tray_exist_bits and benefit from the cleanup.
merged_ams_dict = new_state.get("ams")
if isinstance(merged_ams_dict, dict):
units = merged_ams_dict.get("ams")
apply_tray_exist_bits(
units if isinstance(units, list) else [],
merged_ams_dict.get("tray_exist_bits"),
power_on_flag=merged_ams_dict.get("power_on_flag", True),
log_label=self.vp_name,
)
self._latest_print_state = new_state
dump_wire(self.vp_name, "in", new_state)
return
@@ -1197,6 +1197,140 @@ class TestAMSTrayStateClearning:
assert tray0["remain"] == 75
class TestApplyTrayExistBitsHelper:
"""Direct contract pinning for the shared ``apply_tray_exist_bits`` helper.
The same logic is exercised end-to-end via ``_handle_ams_data`` in the
internal-state suite and via ``_on_printer_raw`` in the bridge suite,
but those go through the merge / cache layers — the helper itself
deserves direct coverage so future refactors don't silently change
the contract both callers depend on (#1726).
"""
def test_returns_zero_on_missing_bits(self):
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]
assert apply_tray_exist_bits(units, None) == 0
assert apply_tray_exist_bits(units, "") == 0
# Untouched.
assert units[0]["tray"][0]["tray_type"] == "PLA"
def test_returns_zero_on_unparseable_bits(self):
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]
assert apply_tray_exist_bits(units, "garbage") == 0
assert units[0]["tray"][0]["tray_type"] == "PLA"
def test_shutdown_guard_zero_bits_with_power_off_skips(self):
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}]
cleared = apply_tray_exist_bits(units, "0", power_on_flag=False)
assert cleared == 0
# Slot preserved — wiping here would propagate phantom empties on
# every printer-off push.
assert units[0]["tray"][0]["tray_type"] == "PLA"
def test_zero_bits_with_power_on_still_clears(self):
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}]
cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
# Slot is genuinely empty per the printer's report.
assert cleared == 1
assert units[0]["tray"][0]["state"] == 9
assert units[0]["tray"][0]["tray_type"] == ""
def test_nonzero_bits_with_power_off_still_clears_removed_slot(self):
"""#1365: X1C reports power_on_flag=False between prints while the
AMS keeps reporting its actual slot inventory. The guard must skip
ONLY the all-zero + power-off combination, not nonzero + power-off.
"""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"},
{"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF"},
],
}
]
# 0x1 = slot 0 loaded, slot 1 empty. Power off (steady-state idle).
cleared = apply_tray_exist_bits(units, "1", power_on_flag=False)
assert cleared == 1
assert units[0]["tray"][0]["tray_type"] == "PLA"
assert units[0]["tray"][1]["tray_type"] == ""
def test_promotes_state_to_int_nine(self):
"""Downstream `tray_state in {9, 10}` uses `==` — int 9, not "9"."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "state": "11"}]}]
apply_tray_exist_bits(units, "0", power_on_flag=True)
assert units[0]["tray"][0]["state"] == 9
assert isinstance(units[0]["tray"][0]["state"], int)
def test_ams_ht_unit_skipped(self):
"""AMS-HT (id >= 128) uses a different addressing scheme."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
assert cleared == 0
assert units[0]["tray"][0]["tray_type"] == "PLA"
def test_string_ids_handled(self):
"""Bridge cache stores ids as strings (JSON wire format)."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [
{
"id": "0",
"tray": [
{"id": "0", "tray_type": "PLA"},
{"id": "1", "tray_type": "PETG"},
],
}
]
# 0x1 = bit 0 set (slot 0), bit 1 clear (slot 1 empty).
cleared = apply_tray_exist_bits(units, "1", power_on_flag=True)
assert cleared == 1
assert units[0]["tray"][0]["tray_type"] == "PLA"
assert units[0]["tray"][1]["tray_type"] == ""
def test_multi_ams_global_bit_math(self):
"""global_bit = ams_id * 4 + tray_id. Verify AMS 1 slots use
bits 4-7 of the mask, not bits 0-3."""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [
{"id": 0, "tray": [{"id": i, "tray_type": "PLA"} for i in range(4)]},
{"id": 1, "tray": [{"id": i, "tray_type": "PETG"} for i in range(4)]},
]
# 0x0f: all slots of AMS 0 loaded, all slots of AMS 1 empty.
cleared = apply_tray_exist_bits(units, "f", power_on_flag=True)
assert cleared == 4
for i in range(4):
assert units[0]["tray"][i]["tray_type"] == "PLA"
assert units[1]["tray"][i]["tray_type"] == ""
def test_state_promoted_even_when_no_stale_data(self):
"""Slot without `tray_type` still gets state=9 — the bitmask is
authoritative, the field wipe just avoids extra log lines.
"""
from backend.app.services.bambu_mqtt import apply_tray_exist_bits
units = [{"id": 0, "tray": [{"id": 0, "state": "11"}]}]
cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
# No tray_type to clear → cleared counter stays 0 but state is set.
assert cleared == 0
assert units[0]["tray"][0]["state"] == 9
class TestNozzleRackData:
"""Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
+181 -1
View File
@@ -670,7 +670,12 @@ class TestPushStatusCache:
{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
{"id": "1", "tray": [{"id": "0", "tray_type": "PETG"}]},
],
"tray_exist_bits": "3",
# bit 0 (AMS 0 slot 0) + bit 4 (AMS 1 slot 0) = 0x11.
# `_on_printer_raw` now applies the #1726 bitmask
# cleanup to the cached state, so the test fixture
# must declare both loaded slots — same shape the
# real printer sends.
"tray_exist_bits": "11",
},
}
}
@@ -704,6 +709,181 @@ class TestPushStatusCache:
await bridge.stop()
@pytest.mark.asyncio
async def test_tray_exist_bits_clears_empty_slots_in_slicer_cache(self):
"""#1726 (reported by @needo37): the bridge cache forwards the real
printer's raw AMS payload to the slicer. Without the empty-slot
cleanup that bambu_mqtt.py applies to Bambuddy's internal state, the
cached units carried stale `tray_type` / `tray_color` /
`tray_info_idx` for slots whose `tray_exist_bits` bit was 0 — and
BambuStudio's Sync rendered those empty slots as phantom loaded
filaments. After the fix the bridge runs the same shared
``apply_tray_exist_bits`` helper before storing the cache.
"""
server = _make_server()
bridge = _make_bridge(server)
await bridge.start()
# Pushall: AMS 0 has slots 0/1/2/3; only slots 1, 2, 3 are loaded.
# Slot 0 carries stale data (RFID/color/material from a previously
# loaded spool). `tray_exist_bits` = 0xe = 0b1110 → bit 0 unset.
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",
"tray_color": "FF0000FF",
"tray_info_idx": "GFL00",
"tag_uid": "1234567890abcdef",
"tray_uuid": "abcdef1234567890abcdef1234567890",
"remain": 75,
"state": "11",
},
{"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
{"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
{"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
],
}
],
"tray_exist_bits": "e",
},
}
}
).encode(),
)
await asyncio.sleep(0.01)
cached = bridge.get_latest_print_state()
slot0 = cached["ams"]["ams"][0]["tray"][0]
# Empty slot: stale per-tray fields wiped, state promoted to 9.
assert slot0["state"] == 9, "empty slot must be promoted to state=9"
assert slot0["tray_type"] == ""
assert slot0["tray_color"] == ""
assert slot0["tray_info_idx"] == ""
assert slot0["tag_uid"] == "0000000000000000"
assert slot0["tray_uuid"] == "00000000000000000000000000000000"
assert slot0["remain"] == 0
# Loaded slots preserved.
assert cached["ams"]["ams"][0]["tray"][1]["tray_type"] == "PETG"
assert cached["ams"]["ams"][0]["tray"][2]["tray_type"] == "ABS"
assert cached["ams"]["ams"][0]["tray"][3]["tray_type"] == "TPU"
await bridge.stop()
@pytest.mark.asyncio
async def test_tray_exist_bits_shutdown_guard_preserves_cache(self):
"""#765 shutdown guard mirrored at the bridge: when the printer
powers off it sends all-zero `tray_exist_bits` paired with
`power_on_flag=False`. Wiping the cache on that pattern would
propagate phantom empties to every slicer reconnect until the
printer powers back on and pushes a real state. Skip cleanup
on the shutdown-shaped payload."""
server = _make_server()
bridge = _make_bridge(server)
await bridge.start()
# 1. Normal pushall — all four slots loaded.
bridge._on_printer_raw(
f"device/{H2D_SERIAL}/report",
json.dumps(
{
"print": {
"command": "push_status",
"ams": {
"ams": [
{
"id": "0",
"tray": [
{"id": str(i), "tray_type": "PLA", "tray_color": f"{i:02x}{i:02x}{i:02x}FF"}
for i in range(4)
],
}
],
"tray_exist_bits": "f",
"power_on_flag": True,
},
}
}
).encode(),
)
await asyncio.sleep(0.01)
# 2. Shutdown-shaped push: tray_exist_bits=0 + power_on_flag=False.
bridge._on_printer_raw(
f"device/{H2D_SERIAL}/report",
json.dumps(
{
"print": {
"command": "push_status",
"ams": {
"tray_exist_bits": "0",
"power_on_flag": False,
},
}
}
).encode(),
)
await asyncio.sleep(0.01)
cached = bridge.get_latest_print_state()
for i in range(4):
assert cached["ams"]["ams"][0]["tray"][i]["tray_type"] == "PLA", f"slot {i} must survive the shutdown push"
await bridge.stop()
@pytest.mark.asyncio
async def test_tray_exist_bits_skips_ams_ht_units(self):
"""AMS-HT units (id >= 128) use a separate addressing scheme and
must not be touched by the bitmask cleanup — bit math at
global_bit = ams_id * 4 + tray_id would overrun normal AMS bits.
Pin the skip so future AMS-HT support doesn't accidentally wipe
loaded HT slots.
"""
server = _make_server()
bridge = _make_bridge(server)
await bridge.start()
bridge._on_printer_raw(
f"device/{H2D_SERIAL}/report",
json.dumps(
{
"print": {
"command": "push_status",
"ams": {
"ams": [
{
"id": "128",
"tray": [
{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
],
}
],
"tray_exist_bits": "0",
"power_on_flag": True,
},
}
}
).encode(),
)
await asyncio.sleep(0.01)
cached = bridge.get_latest_print_state()
ht_slot = cached["ams"]["ams"][0]["tray"][0]
# tray_exist_bits="0" alone would normally wipe — but AMS-HT is
# skipped, so the HT slot keeps its loaded data.
assert ht_slot["tray_type"] == "PLA"
await bridge.stop()
@pytest.mark.asyncio
async def test_partial_ams_tray_update_preserves_other_trays(self):
"""Same shape as the unit-level test but at the tray level. AMS