mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
fix(usage-tracker): split filament weight when AMS auto-falls-back mid-print (issue 957)
When one spool ran out and the AMS transparently switched to a sibling
slot of the same material, the usage tracker credited the originally-
mapped spool with the full 3MF estimate AND added the fallback spool's
remain%-delta on top — so a 78g print could record as 138g across two
spools, leaving the empty spool's recorded weight beyond its label.
Two interacting bugs:
1. bambu_mqtt.py: the tray-change recorder gated on
`state in ("RUNNING", "PAUSE")`, but P2S firmware briefly transitions
out of RUNNING during the AMS swap (into LOADING etc.), so the
literal-string gate missed the switch entirely and tray_change_log
stayed empty. Re-key on the print-lifecycle flags
(_was_running and not _completion_triggered) so any tray change
between print start and completion is captured regardless of the
momentary gcode_state.
2. usage_tracker.py: the splitting branch was gated on
`not slot_to_tray`, so the splitting code only ran for prints where
the slicer mapping hadn't been captured — i.e. never on the actual
fallback case (slot_to_tray is populated by every print_cmd). Drop
the gate: when tray_change_log has > 1 entries, splitting takes
over and per-segment per-layer gcode usage replaces the stale
mapping. Path 2 (AMS remain%-delta) then naturally skips both trays
because they're already in handled_trays after splitting,
eliminating the double-credit.
This commit is contained in:
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Slicer Bundle (.bbscfg) import — pick presets from a stored bundle instead of resolving cloud/local/standard PresetRefs every slice** — Closes the long tail of preset-resolution corner cases (cloud presets behind login, "from User" sentinel handling, the `# `-prefix clone trick, dangling `inherits` on renamed parents, etc.) by letting users upload a BambuStudio "Printer Preset Bundle" (`.bbscfg`) once per printer and pick from it for every subsequent slice. **Service layer (`backend/app/services/slicer_api.py`):** `BundleSummary` / `BundleNotFoundError` types, `import_bundle` / `list_bundles` / `get_bundle` / `delete_bundle` methods, `slice_with_bundle` which posts `/slice` with bundle id + per-category preset names instead of the JSON triplet. **Routes (`/api/v1/slicer/bundles`, all gated on `Permission.LIBRARY_UPLOAD`):** `POST` / `GET` / `GET :id` / `DELETE :id`. All routes proxy via `_resolve_slicer_api_url` so they follow the user's `preferred_slicer` setting (bambu_studio vs orcaslicer). Status-code mapping treats sidecar 4xx as 400, `BundleNotFoundError` as 404, sidecar unreachable as 503, and sidecar 5xx as 502. **Preview-slice (`backend/app/services/slice_preview.py::get_preview_filaments`)** picks up optional `bundle_id` + `printer_name` + `process_name` + `filament_names` params and routes through `slice_with_bundle` when set; the cache key picks up a bundle-context fingerprint so different bundle picks on the same file occupy distinct entries — gram numbers in the preview now match what the real print will produce instead of being derived from the file's embedded process settings (which can drift from the triplet the actual slice would use). The `library.py` and `archives.py` `/filament-requirements` routes forward the new params. **Dispatch (`SliceRequest.bundle: SliceBundleSpec`):** when set, `_run_slicer_with_fallback` skips `resolve_preset_ref` and calls `slice_with_bundle`; the validator skips the preset-required check so bundle-only requests validate. 3MF + bundle CLI 5xx still falls back to the embedded-settings slice path (`used_embedded_settings=True` surfaces in the response), and sidecar 404 (unknown bundle / preset name) maps to 400. **Frontend SliceModal Bundle tier:** new "Slicer bundle" picker at the top of the modal, rendered only when at least one bundle is imported (`GET /slicer/bundles` non-empty). Selecting a bundle replaces cloud / local / standard preset dropdowns with bundle-scoped pickers (process + per-slot filament names from the bundle) — printer is implicit (each `.bbscfg` has exactly one). "None" leaves the modal on the original preset-triplet path. Submit routes through `SliceRequest.bundle` so the backend skips PresetRef resolution and asks the sidecar to materialise the JSON triplet from the stored bundle by name. **Frontend types:** `SliceBundleSpec` + `bundle?: SliceBundleSpec` on `SliceRequest`; `getLibraryFileFilamentRequirements` / `getArchiveFilamentRequirements` accept an optional 4th-arg bundle context object. The orca-slicer-api fork's bundle endpoints (shipped on `bambuddy/bundle-import`) are the server side of this — see the slicer-api sidecar docker-compose for the matching versions.
|
||||
|
||||
### Fixed
|
||||
- **Filament usage double-counted when AMS auto-falls-back to a same-material spool** ([#957](https://github.com/maziggy/bambuddy/issues/957)) — When one spool ran out mid-print and the AMS transparently switched to a sibling slot loaded with the same material, the usage tracker credited the originally-mapped spool with the full 3MF estimate AND added the fallback spool's remain%-delta on top — so a 78 g print could show as 78 g + 60 g = 138 g consumed across the two spools, leaving the empty spool's recorded weight beyond its label weight (the symptom the original report flagged on a 1209 g spool reading "1188.30 g used" while the new spool only got a 30 g credit). Two interacting bugs: (1) the tray-change recorder in `bambu_mqtt.py` gated on `state in ("RUNNING", "PAUSE")` literal strings, and P2S firmware briefly transitions out of RUNNING during the AMS swap, so the switch was never appended to `tray_change_log`; (2) the usage-tracker splitting branch in `usage_tracker.py` was gated on `not slot_to_tray`, so even when the tray-change log was populated the splitting code only ran for prints where the slicer's mapping had not been captured — i.e. never on the actual fallback case. **Fix:** the `bambu_mqtt.py` gate now keys on the print-lifecycle flags (`_was_running and not _completion_triggered`) so any tray change between print start and completion is captured regardless of the momentary `gcode_state` string. The `usage_tracker.py` gate is split so `tray_change_log` evidence with > 1 entries always takes over from `slot_to_tray`, treating the per-segment per-layer gcode usage as the source of truth when the printer actually fed from multiple trays. Path 2 (AMS remain%-delta fallback) then naturally skips both trays because they're already in `handled_trays` after splitting, eliminating the double-credit. **Tests:** new `test_tray_change_recorded_during_intermediate_state` and `test_tray_change_not_recorded_after_completion` in `test_bambu_mqtt.py` exercising the new gate; new `test_tray_switch_overrides_print_cmd_mapping` in `test_usage_tracker.py` pinning that with `ams_mapping=[0]` set and `tray_change_log=[(0,0),(1,30)]` the splitter produces two segments summing to the 3MF estimate (no double-count) and adds both `(0,0)` and `(0,1)` to `handled_trays`.
|
||||
- **3D Preview returned `{"detail":"Not Found"}` in Docker installs** ([#1218](https://github.com/maziggy/bambuddy/issues/1218)) — The embedded GCode viewer's static assets (`gcode_viewer/`) were not copied into the production Docker image, so clicking "3D Preview" on any archive loaded an iframe at `/gcode-viewer/?archive=<id>` that returned a bare FastAPI 404 — Firefox / Chrome rendered the JSON response inside the iframe area while the outer Bambuddy layout looked normal, masking the failure unless the user actually inspected the iframe. The Vite production build doesn't stage `gcode_viewer/` into `static/` either (the dev server serves it via a `configureServer` middleware that's dev-only), and the only integration test for the route accepted `404` as a valid outcome ("`assert response.status_code in (200, 404)`") so CI never caught the missing files. Affected every Docker build since the embedded viewer landed in 0.2.4b1 (commit `3adce435`, 2026-04-22). **Fix:** `Dockerfile` now copies the `gcode_viewer/` directory alongside the React build output. **Defence in depth:** `backend/app/main.py` logs an ERROR at startup when `_gcode_viewer_dir / "index.html"` is missing so future packaging gaps surface in `docker logs` and the support bundle instead of as silent runtime 404s. **Test guard:** `backend/tests/integration/test_gcode_viewer.py` adds `test_gcode_viewer_index_served_when_assets_present` which skips when the directory is intentionally absent (unit-test environments) but asserts `200 OK` + a non-empty HTML body when the assets do exist on disk — so a future broken `COPY` fails CI loudly rather than continuing to ship a broken image.
|
||||
- **Slice button no longer enabled before the preview slice resolves** — Until the preview slice (or embedded-metadata read for already-sliced 3MFs) returned the per-plate filament list, the SliceModal rendered a synthetic single-slot fallback so the auto-pick had something to bind against. That made the Slice button enabled the moment the modal opened, even before the slicer had told us which AMS slots the plate actually consumes — clicking would dispatch against opaque defaults and the real-life print would either pick the wrong filament or fail with a slot-mismatch error after the fact. Adds `filamentReqsQuery.isSuccess` to the `isReady` chain so the button stays disabled while the preview slice is in flight (or before the backend's `/filament-requirements` call settles for sliced files) and flips to enabled the moment the real slot list lands and auto-pick fills it.
|
||||
- **Docker data-volume ownership normalised at startup via gosu entrypoint** ([#1211](https://github.com/maziggy/bambuddy/issues/1211)) — Two long-standing failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as `root:root`, and the previous `chmod 777 /app/data` Dockerfile workaround only covered the named-volume root — so subdirs Bambuddy creates at runtime (`virtual_printer/uploads`, `virtual_printer/certs`, etc.) inherited wrong ownership when the container ran as `1000:1000`. (2) The shipped `docker-compose.yml` ships `./virtual_printer:/app/data/virtual_printer` uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: `[Errno 13] Permission denied: '/app/data/virtual_printer/uploads'`, no virtual printer ever starts, "VP doesn't work" support reports follow. **Replaces the chmod-777 hack with a proper entrypoint:** `deploy/docker-entrypoint.sh` runs as root, chowns `/app/data` and `/app/logs` (and `/app/data/virtual_printer` when bind-mounted) to `PUID:PGID`, then drops to that uid via `gosu` before `exec`'ing the app. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal — no multi-second startup penalty on multi-GB archive directories. A sentinel `.bambuddy` file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit `user:` directive or `--user` flag the entrypoint detects it isn't root and falls through to direct `exec` — preserving compatibility for users who pin a specific uid. **Compose template changes:** removes `user: "${PUID:-1000}:${PGID:-1000}"` (the entrypoint owns privilege drop now), adds `PUID` / `PGID` env vars with the same defaults, and comments out the `./virtual_printer:/app/data/virtual_printer` bind mount by default with explicit "only needed if you also run a native install of Bambuddy on the same host and want both to share the VP CA cert" guidance. The entrypoint chowns the host-side dir through the bind mount the first time it sees wrong ownership, so existing uncommented installs continue to work and #1211 specifically gets fixed.
|
||||
|
||||
@@ -1569,8 +1569,14 @@ class BambuMQTTClient:
|
||||
# Valid physical trays: 0-15 (regular AMS), 128-135 (AMS-HT), 254 (external spool)
|
||||
tn = self.state.tray_now
|
||||
if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
|
||||
# Log tray change for mid-print usage splitting
|
||||
if tn != self.state.last_loaded_tray and self.state.state in ("RUNNING", "PAUSE"):
|
||||
# Log tray change for mid-print usage splitting. Gate on the
|
||||
# print-lifecycle flags (`_was_running` set on first RUNNING /
|
||||
# new print, `_completion_triggered` set when on_print_complete
|
||||
# fires) instead of `state in ("RUNNING", "PAUSE")` — P2S
|
||||
# firmware briefly transitions out of RUNNING during AMS
|
||||
# auto-fallback (#957), so a literal-string gate misses the
|
||||
# switch and the usage tracker double-credits at completion.
|
||||
if tn != self.state.last_loaded_tray and self._was_running and not self._completion_triggered:
|
||||
self.state.tray_change_log.append((tn, self.state.layer_num))
|
||||
logger.info(
|
||||
"[%s] Tray change during print: tray=%d at layer=%d",
|
||||
|
||||
@@ -846,26 +846,29 @@ async def _track_from_3mf(
|
||||
# 5. For single-filament non-queue prints, use tray_now from printer state
|
||||
# Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
|
||||
# > last_loaded_tray > vt_tray check
|
||||
#
|
||||
# tray_change_log evidence wins over slot_to_tray when present: if the
|
||||
# printer fed from multiple trays mid-print (AMS auto-fallback when one
|
||||
# spool runs out, #957), the slicer's mapping captured at print start
|
||||
# is stale and needs to be replaced with per-layer split attribution.
|
||||
nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
|
||||
tray_now_override: int | None = None
|
||||
tray_changes: list[tuple[int, int]] = [] # [(global_tray_id, layer_num), ...]
|
||||
if not slot_to_tray and len(nonzero_slots) == 1:
|
||||
state = printer_manager.get_status(printer_id)
|
||||
tray_changes = getattr(state, "tray_change_log", []) if state else []
|
||||
state = printer_manager.get_status(printer_id) if len(nonzero_slots) == 1 else None
|
||||
if state is not None:
|
||||
tray_changes = getattr(state, "tray_change_log", []) or []
|
||||
|
||||
if len(tray_changes) > 1:
|
||||
# Multi-tray usage detected — will split in per-slot loop using per-layer gcode
|
||||
logger.info("[UsageTracker] 3MF: tray change log: %s (will split weight)", tray_changes)
|
||||
elif 0 <= tray_now_at_start <= 254:
|
||||
# Try tray_now_at_start first (captured at print start)
|
||||
if len(tray_changes) > 1:
|
||||
# Multi-tray usage detected — splitting takes over regardless of slot_to_tray.
|
||||
logger.info("[UsageTracker] 3MF: tray change log: %s (will split weight)", tray_changes)
|
||||
elif not slot_to_tray and len(nonzero_slots) == 1:
|
||||
if 0 <= tray_now_at_start <= 254:
|
||||
tray_now_override = tray_now_at_start
|
||||
logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
|
||||
elif state and 0 <= state.tray_now <= 254:
|
||||
# Current state is valid (printer didn't retract yet)
|
||||
tray_now_override = state.tray_now
|
||||
logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
|
||||
elif state and 0 <= state.last_loaded_tray <= 253:
|
||||
# Last valid tray before retract (H2D retracts before completion callback)
|
||||
tray_now_override = state.last_loaded_tray
|
||||
logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
|
||||
elif state and state.tray_now == 255:
|
||||
@@ -874,7 +877,7 @@ async def _track_from_3mf(
|
||||
if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
|
||||
tray_now_override = state.tray_now
|
||||
logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
|
||||
if tray_now_override is None and len(tray_changes) <= 1:
|
||||
if tray_now_override is None:
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
|
||||
tray_now_at_start,
|
||||
|
||||
@@ -2731,83 +2731,129 @@ class TestTrayChangeLog:
|
||||
|
||||
assert mqtt_client.state.tray_change_log == [(1, 0)]
|
||||
|
||||
# Helper that mirrors the production gate at bambu_mqtt.py:1571 — tests
|
||||
# below replicate the gate so they validate the *contract* without needing
|
||||
# to feed a synthetic AMS push through the full _process_message path.
|
||||
@staticmethod
|
||||
def _record_if_change(client, tn: int) -> None:
|
||||
if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
|
||||
if tn != client.state.last_loaded_tray and client._was_running and not client._completion_triggered:
|
||||
client.state.tray_change_log.append((tn, client.state.layer_num))
|
||||
client.state.last_loaded_tray = tn
|
||||
|
||||
def test_tray_change_recorded_during_running(self, mqtt_client):
|
||||
"""Tray change while RUNNING is appended to the log."""
|
||||
mqtt_client.state.state = "RUNNING"
|
||||
mqtt_client._was_running = True
|
||||
mqtt_client._completion_triggered = False
|
||||
mqtt_client.state.layer_num = 50
|
||||
mqtt_client.state.last_loaded_tray = 0
|
||||
mqtt_client.state.tray_change_log = [(0, 0)]
|
||||
|
||||
# Simulate tray_now update via AMS data
|
||||
mqtt_client.state.tray_now = 1
|
||||
# Trigger the tracking code path
|
||||
tn = mqtt_client.state.tray_now
|
||||
if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
|
||||
mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
|
||||
mqtt_client.state.last_loaded_tray = tn
|
||||
self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
|
||||
|
||||
assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
|
||||
|
||||
def test_tray_change_not_recorded_when_idle(self, mqtt_client):
|
||||
"""Tray changes while IDLE are NOT logged."""
|
||||
"""Tray changes outside an active print are NOT logged."""
|
||||
# IDLE between prints — both lifecycle flags in the cleared state.
|
||||
mqtt_client.state.state = "IDLE"
|
||||
mqtt_client._was_running = False
|
||||
mqtt_client._completion_triggered = False
|
||||
mqtt_client.state.layer_num = 0
|
||||
mqtt_client.state.last_loaded_tray = 0
|
||||
mqtt_client.state.tray_change_log = []
|
||||
|
||||
mqtt_client.state.tray_now = 3
|
||||
tn = mqtt_client.state.tray_now
|
||||
if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
|
||||
mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
|
||||
mqtt_client.state.last_loaded_tray = tn
|
||||
self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
|
||||
|
||||
assert mqtt_client.state.tray_change_log == []
|
||||
|
||||
def test_tray_change_recorded_during_pause(self, mqtt_client):
|
||||
"""Tray change while PAUSE is also logged (AMS can swap during pause)."""
|
||||
mqtt_client.state.state = "PAUSE"
|
||||
mqtt_client._was_running = True
|
||||
mqtt_client._completion_triggered = False
|
||||
mqtt_client.state.layer_num = 75
|
||||
mqtt_client.state.last_loaded_tray = 2
|
||||
mqtt_client.state.tray_change_log = [(2, 0)]
|
||||
|
||||
mqtt_client.state.tray_now = 5
|
||||
tn = mqtt_client.state.tray_now
|
||||
if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
|
||||
mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
|
||||
mqtt_client.state.last_loaded_tray = tn
|
||||
self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
|
||||
|
||||
assert mqtt_client.state.tray_change_log == [(2, 0), (5, 75)]
|
||||
|
||||
def test_tray_change_recorded_during_intermediate_state(self, mqtt_client):
|
||||
"""Tray change during a transient non-RUNNING state mid-print is logged.
|
||||
|
||||
Regression for #957: P2S firmware briefly transitions out of RUNNING
|
||||
(e.g. into LOADING) when the AMS auto-falls-back from an empty spool to
|
||||
a same-material sibling. The previous gate ``state in ("RUNNING",
|
||||
"PAUSE")`` missed this transition entirely, so the usage tracker had no
|
||||
evidence of the switch and double-credited the original tray with the
|
||||
full 3MF estimate while the remain%-delta path added the fallback
|
||||
weight on top. The new gate keys on the print-lifecycle flags
|
||||
(``_was_running and not _completion_triggered``) so any tray change
|
||||
between print start and completion is captured regardless of the
|
||||
momentary gcode_state string.
|
||||
"""
|
||||
mqtt_client.state.state = "LOADING" # not RUNNING/PAUSE — old gate would skip
|
||||
mqtt_client._was_running = True
|
||||
mqtt_client._completion_triggered = False
|
||||
mqtt_client.state.layer_num = 42
|
||||
mqtt_client.state.last_loaded_tray = 0
|
||||
mqtt_client.state.tray_change_log = [(0, 0)]
|
||||
|
||||
# AMS auto-fallback: T0 ran out, swapped to T1 of same material
|
||||
mqtt_client.state.tray_now = 1
|
||||
self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
|
||||
|
||||
assert mqtt_client.state.tray_change_log == [(0, 0), (1, 42)]
|
||||
|
||||
def test_tray_change_not_recorded_after_completion(self, mqtt_client):
|
||||
"""Once on_print_complete has fired, further tray changes don't pollute the log."""
|
||||
mqtt_client.state.state = "FINISH"
|
||||
mqtt_client._was_running = True
|
||||
mqtt_client._completion_triggered = True # completion already triggered
|
||||
mqtt_client.state.layer_num = 0
|
||||
mqtt_client.state.last_loaded_tray = 1
|
||||
mqtt_client.state.tray_change_log = [(0, 0), (1, 50)]
|
||||
|
||||
mqtt_client.state.tray_now = 2
|
||||
self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
|
||||
|
||||
# Log unchanged — completion already triggered so post-print tray
|
||||
# movement (e.g. printer self-cleaning) doesn't bleed into the next
|
||||
# print's attribution.
|
||||
assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
|
||||
|
||||
def test_same_tray_not_logged_twice(self, mqtt_client):
|
||||
"""Same tray value doesn't create duplicate log entries."""
|
||||
mqtt_client.state.state = "RUNNING"
|
||||
mqtt_client._was_running = True
|
||||
mqtt_client._completion_triggered = False
|
||||
mqtt_client.state.layer_num = 30
|
||||
mqtt_client.state.last_loaded_tray = 2
|
||||
mqtt_client.state.tray_change_log = [(2, 0)]
|
||||
|
||||
# Same tray again
|
||||
mqtt_client.state.tray_now = 2
|
||||
tn = mqtt_client.state.tray_now
|
||||
if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
|
||||
mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
|
||||
mqtt_client.state.last_loaded_tray = tn
|
||||
self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
|
||||
|
||||
assert mqtt_client.state.tray_change_log == [(2, 0)]
|
||||
|
||||
def test_multiple_tray_changes(self, mqtt_client):
|
||||
"""Multiple tray changes create a full history."""
|
||||
mqtt_client.state.state = "RUNNING"
|
||||
mqtt_client._was_running = True
|
||||
mqtt_client._completion_triggered = False
|
||||
mqtt_client.state.last_loaded_tray = 0
|
||||
mqtt_client.state.tray_change_log = [(0, 0)]
|
||||
|
||||
changes = [(1, 50), (3, 120), (0, 200)]
|
||||
for tray, layer in changes:
|
||||
for tray, layer in [(1, 50), (3, 120), (0, 200)]:
|
||||
mqtt_client.state.tray_now = tray
|
||||
mqtt_client.state.layer_num = layer
|
||||
tn = mqtt_client.state.tray_now
|
||||
if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
|
||||
mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
|
||||
mqtt_client.state.last_loaded_tray = tn
|
||||
self._record_if_change(mqtt_client, tray)
|
||||
|
||||
assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50), (3, 120), (0, 200)]
|
||||
|
||||
|
||||
@@ -1101,6 +1101,84 @@ class TestTrayChangeSplit:
|
||||
assert results[1]["tray_id"] == 1
|
||||
assert results[1]["weight_used"] == 30.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tray_switch_overrides_print_cmd_mapping(self):
|
||||
"""tray_change_log evidence overrides slot_to_tray captured at print start.
|
||||
|
||||
Regression for #957: when AMS auto-falls-back from an empty spool to a
|
||||
same-material sibling, the print_cmd's mapping (which named the
|
||||
original tray) is stale by the time the print finishes. Before this
|
||||
fix, the splitting branch was gated on ``not slot_to_tray`` so the
|
||||
slicer mapping was preferred even when the printer actually fed from
|
||||
a different tray — Path 1 credited the original tray with the full
|
||||
3MF estimate and Path 2 layered the AMS-fallback delta on top, so
|
||||
spool consumption double-counted (e.g. 78 g print credited as 78 g
|
||||
+ 60 g = 138 g). This test pins the new behavior: when
|
||||
tray_change_log has > 1 entries, splitting takes over regardless of
|
||||
whether ams_mapping was provided.
|
||||
"""
|
||||
spool_a = _make_spool(spool_id=10, label_weight=1000)
|
||||
spool_b = _make_spool(spool_id=20, label_weight=1000)
|
||||
assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=0)
|
||||
assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
|
||||
archive = _make_archive(archive_id=200)
|
||||
|
||||
# No queue_item placeholder: passing ams_mapping bypasses the queue lookup
|
||||
# at usage_tracker.py:816 (`if not slot_to_tray and archive_id`).
|
||||
db = _mock_db_sequential([archive, assign_a, spool_a, assign_b, spool_b])
|
||||
|
||||
# Slicer mapping says slot 1 -> tray 0; printer actually swapped to tray 1 at layer 30
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
progress=100,
|
||||
layer_num=100,
|
||||
tray_now=1,
|
||||
last_loaded_tray=1,
|
||||
total_layers=100,
|
||||
tray_change_log=[(0, 0), (1, 30)],
|
||||
)
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 78.0, "type": "PLA", "color": ""}]
|
||||
handled_trays: set[tuple[int, int]] = set()
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=filament_usage,
|
||||
),
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
|
||||
return_value=None, # No per-layer data — exercises linear fallback
|
||||
),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=200,
|
||||
status="completed",
|
||||
print_name="Runout w/ slicer mapping",
|
||||
handled_trays=handled_trays,
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
# ams_mapping captured at print start — slicer told us slot 0 -> tray 0
|
||||
# (1-based slot_id=1 -> 0-based slot index 0).
|
||||
ams_mapping=[0],
|
||||
)
|
||||
|
||||
# Splitting branch ran despite ams_mapping being set: two segments,
|
||||
# one per tray, total weight matches the 3MF estimate (no double-count).
|
||||
assert len(results) == 2
|
||||
total = sum(r["weight_used"] for r in results)
|
||||
assert total == pytest.approx(78.0, abs=0.1)
|
||||
# Both trays now in handled_trays so Path 2 (remain%-delta) skips them.
|
||||
assert (0, 0) in handled_trays
|
||||
assert (0, 1) in handled_trays
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tray_change_uses_normal_path(self):
|
||||
"""Single-entry tray_change_log falls through to normal tray_now_at_start logic."""
|
||||
|
||||
Reference in New Issue
Block a user