mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
fix(vp): scope saved AMS mapping to the printer it was resolved against
Round-2 review fixes for #2700. Blocking: the toggle didn't actually gate the archive write. archive.py's promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's request-topic interception captures ams_mapping unconditionally for every print source (slicer-direct LAN prints included). Since main.py's real-printer auto-archive path forwards the full MQTT payload as print_data, every archive on any install — VP or not — grew extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing with an explicit `slicer_ams_mapping` param on archive_print() that only the VP-queue path (already gated on save_ams_mapping) ever passes. Blocking: a saved mapping could get reused on a printer it was never resolved against — tray IDs only mean something relative to one printer's AMS layout. extra_data.slicer_ams_mapping is now stored as {mapping, printer_id} instead of a bare array: - add_to_queue's fallback only fires when the reprint's target printer_id matches the mapping's origin printer. - The frontend's archiveAmsMapping only surfaces (and the Mapping button only appears) when the print modal's selected printer matches too. - A model-based VP (target_printer_id=None, no MQTT bridge to any real printer) never stamps a mapping in the first place — there's no live AMS layout for the slicer to have resolved tray IDs against. Also from review: - Multi-plate archives now get the Mapping button too (the per-plate FilamentMapping loop was missing archiveAmsMapping entirely). - Added coverage for the previously-untested late-MQTT archive patch path (_restamp_recent_queue_item), including the model-based-VP skip case. - usingArchiveMapping now also resets on printer change, not just plate/archive (it already worked via the printer-scoping above, but is now an explicit dependency too). - The Mapping button's revert (OFF) now undoes only the slots it itself set, not every manual pick in scope — matches the comment above it. - Added a comment on why negative-value slots (external spool) are skipped rather than cleared when applying a saved mapping.
This commit is contained in:
@@ -657,15 +657,27 @@ async def add_to_queue(
|
||||
# to the exact same physical spool instead of the scheduler re-deriving a
|
||||
# (possibly ambiguous) mapping from just the file's static type/color.
|
||||
#
|
||||
# Note this is unconditional — it applies regardless of whether the
|
||||
# physical spool in that slot has changed since the original print. #1308
|
||||
# covers re-verifying a stored mapping against live AMS state at dispatch
|
||||
# time; that check is a separate PR and, once merged, will also catch a
|
||||
# stale slot inherited through this fallback.
|
||||
if ams_mapping_json is None and archive and archive.extra_data:
|
||||
saved_mapping = archive.extra_data.get("slicer_ams_mapping")
|
||||
if isinstance(saved_mapping, list) and saved_mapping:
|
||||
ams_mapping_json = json.dumps(saved_mapping)
|
||||
# Global tray IDs only mean something relative to the specific printer
|
||||
# they were resolved against, so this only fires when the reprint targets
|
||||
# that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
|
||||
# for a model-based dispatch (data.printer_id is None) or a reprint aimed
|
||||
# at a different printer, where the same tray number can hold a
|
||||
# completely different spool (#2700 review).
|
||||
#
|
||||
# Note this is otherwise unconditional — it applies regardless of whether
|
||||
# the physical spool in that slot has changed since the original print.
|
||||
# #1308 covers re-verifying a stored mapping against live AMS state at
|
||||
# dispatch time; that check is a separate PR and, once merged, will also
|
||||
# catch a stale slot inherited through this fallback.
|
||||
if ams_mapping_json is None and archive and archive.extra_data and data.printer_id is not None:
|
||||
saved = archive.extra_data.get("slicer_ams_mapping")
|
||||
if (
|
||||
isinstance(saved, dict)
|
||||
and saved.get("printer_id") == data.printer_id
|
||||
and isinstance(saved.get("mapping"), list)
|
||||
and saved["mapping"]
|
||||
):
|
||||
ams_mapping_json = json.dumps(saved["mapping"])
|
||||
items = []
|
||||
for i in range(quantity):
|
||||
item = PrintQueueItem(
|
||||
|
||||
@@ -1144,6 +1144,8 @@ class ArchiveService:
|
||||
prefer_filename_for_name: bool = False,
|
||||
plate_id: int | None = None,
|
||||
library_file_id: int | None = None,
|
||||
slicer_ams_mapping: list[int] | None = None,
|
||||
slicer_ams_mapping_printer_id: int | None = None,
|
||||
) -> PrintArchive | None:
|
||||
"""Archive a 3MF file with metadata.
|
||||
|
||||
@@ -1166,6 +1168,21 @@ class ArchiveService:
|
||||
metadata. Used by virtual-printer flows so users who rename a job in
|
||||
BambuStudio's "send to printer" dialog see that name instead of the
|
||||
creator-baked title (#1152).
|
||||
slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
|
||||
onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
|
||||
a distinct parameter, not read off `print_data["ams_mapping"]` — that key is
|
||||
populated on every MQTT print-start callback regardless of source (bambu_mqtt's
|
||||
request-topic interception captures it for slicer-direct LAN prints too), so
|
||||
promoting it unconditionally would stamp every archive on installs with no
|
||||
virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
|
||||
"Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
|
||||
slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
|
||||
resolved against. Required alongside `slicer_ams_mapping` — a global tray ID
|
||||
only means something relative to one printer's specific AMS layout, so a
|
||||
mapping saved without knowing which printer it came from can't be safely
|
||||
reused later on any printer, including the same one (there'd be no way to
|
||||
tell). A model-based VP with no fixed target printer has no valid value to
|
||||
pass here and must leave both params unset.
|
||||
"""
|
||||
# Verify printer exists if specified
|
||||
if printer_id is not None:
|
||||
@@ -1253,18 +1270,25 @@ class ArchiveService:
|
||||
# Merge with print data from MQTT
|
||||
if print_data:
|
||||
metadata["_print_data"] = print_data
|
||||
# Promote the slicer's own live-resolved AMS-slot pick (captured
|
||||
# from the project_file MQTT command by the VP-queue path — see
|
||||
# `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py)
|
||||
# to a stable top-level extra_data key. Lets a later reprint reuse
|
||||
# the exact tray the user picked/BambuStudio auto-matched at slice
|
||||
# time instead of the scheduler re-deriving one from just the
|
||||
# file's static type/color, which can land on the wrong physical
|
||||
# spool when that match isn't unique. Top-level (not nested under
|
||||
# the `_print_data` diagnostic bag) so API consumers have a single
|
||||
# stable path: `archive.extra_data.slicer_ams_mapping`.
|
||||
if print_data.get("ams_mapping"):
|
||||
metadata["slicer_ams_mapping"] = print_data["ams_mapping"]
|
||||
|
||||
# Promote the slicer's own live-resolved AMS-slot pick, when the caller
|
||||
# explicitly opted in (see the `slicer_ams_mapping` param docstring for
|
||||
# why this is NOT read off `print_data["ams_mapping"]`), to a stable
|
||||
# top-level extra_data key. Lets a later reprint reuse the exact tray
|
||||
# the user picked/BambuStudio auto-matched at slice time instead of the
|
||||
# scheduler re-deriving one from just the file's static type/color,
|
||||
# which can land on the wrong physical spool when that match isn't
|
||||
# unique. Top-level (not nested under the `_print_data` diagnostic bag)
|
||||
# so API consumers have a single stable path:
|
||||
# `archive.extra_data.slicer_ams_mapping`. Stored together with the
|
||||
# printer it was resolved against — see `slicer_ams_mapping_printer_id`
|
||||
# param docstring — so a later reprint can tell whether it's even
|
||||
# applicable before trying to reuse it.
|
||||
if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
|
||||
metadata["slicer_ams_mapping"] = {
|
||||
"mapping": slicer_ams_mapping,
|
||||
"printer_id": slicer_ams_mapping_printer_id,
|
||||
}
|
||||
|
||||
# Determine status and timestamps
|
||||
status = print_data.get("status", "completed") if print_data else "archived"
|
||||
|
||||
@@ -521,7 +521,15 @@ class VirtualPrinterInstance:
|
||||
if raw is not None:
|
||||
patch["nozzle_mapping"] = json.dumps(raw)
|
||||
|
||||
ams_mapping_json = _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
|
||||
# Same target_printer_id gate as the immediate path in
|
||||
# _add_to_print_queue — a model-based VP has no MQTT bridge to a real
|
||||
# printer, so there's no live AMS layout for the slicer to have
|
||||
# resolved tray IDs against.
|
||||
ams_mapping_json = (
|
||||
_extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
|
||||
if self.target_printer_id is not None
|
||||
else None
|
||||
)
|
||||
if ams_mapping_json is not None:
|
||||
patch["ams_mapping"] = ams_mapping_json
|
||||
|
||||
@@ -566,7 +574,10 @@ class VirtualPrinterInstance:
|
||||
archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
|
||||
for archive in archive_result.scalars().all():
|
||||
extra = dict(archive.extra_data or {})
|
||||
extra["slicer_ams_mapping"] = json.loads(ams_mapping_json)
|
||||
extra["slicer_ams_mapping"] = {
|
||||
"mapping": json.loads(ams_mapping_json),
|
||||
"printer_id": self.target_printer_id,
|
||||
}
|
||||
archive.extra_data = extra
|
||||
|
||||
await db.commit()
|
||||
@@ -917,8 +928,17 @@ class VirtualPrinterInstance:
|
||||
# present it makes `_ensure_ams_mapping` skip its own
|
||||
# type/color re-derivation entirely and dispatch use exactly
|
||||
# the tray the slicer/user picked.
|
||||
#
|
||||
# Only trust it when this VP targets one fixed printer. A
|
||||
# model-based ("Any <model>") VP has no MQTT bridge to a real
|
||||
# printer, so the slicer has no live AMS layout to resolve
|
||||
# tray IDs against — whatever it sends here is meaningless
|
||||
# (or, worse, coincidentally valid for the wrong printer once
|
||||
# the scheduler later picks one). Leaving it unset lets the
|
||||
# scheduler's normal type/color re-derivation run against
|
||||
# whichever printer actually gets the job.
|
||||
ams_mapping_json: str | None = None
|
||||
if slicer_opts is not None:
|
||||
if slicer_opts is not None and self.target_printer_id is not None:
|
||||
ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
|
||||
|
||||
service = ArchiveService(db)
|
||||
@@ -929,19 +949,22 @@ class VirtualPrinterInstance:
|
||||
"status": "archived",
|
||||
"source": "virtual_printer",
|
||||
"source_ip": source_ip,
|
||||
# Slicer's own live AMS-slot pick -- promoted to
|
||||
# `extra_data.slicer_ams_mapping` by archive_print() so
|
||||
# a later reprint can reuse it. Opt-in per VP
|
||||
# (`save_ams_mapping`) — only the archive persistence is
|
||||
# gated; the queue item's own `ams_mapping` (used for
|
||||
# *this* dispatch, below) is set unconditionally
|
||||
# whenever the slicer provides it, since that's a
|
||||
# correctness fix, not a feature toggle.
|
||||
"ams_mapping": (
|
||||
json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
|
||||
),
|
||||
},
|
||||
prefer_filename_for_name=prefer_filename,
|
||||
# Slicer's own live AMS-slot pick -- promoted to
|
||||
# `extra_data.slicer_ams_mapping` by archive_print() so a
|
||||
# later reprint can reuse it. Opt-in per VP
|
||||
# (`save_ams_mapping`) — only the archive persistence is
|
||||
# gated; the queue item's own `ams_mapping` (used for
|
||||
# *this* dispatch, below) is set unconditionally whenever
|
||||
# the slicer provides it, since that's a correctness fix,
|
||||
# not a feature toggle. Tagged with the printer it was
|
||||
# resolved against so a later reprint on a *different*
|
||||
# printer knows not to reuse it (#2700 review).
|
||||
slicer_ams_mapping=(
|
||||
json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
|
||||
),
|
||||
slicer_ams_mapping_printer_id=self.target_printer_id,
|
||||
)
|
||||
if archive:
|
||||
logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
|
||||
|
||||
@@ -264,13 +264,16 @@ class TestPrintQueueAPI:
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""When the caller sends no explicit ams_mapping, but the archive
|
||||
carries the slicer's own saved pick (extra_data.slicer_ams_mapping,
|
||||
written by a VP with "Save AMS mapping" on), the queue item should
|
||||
inherit it — the same exact-physical-spool reuse the "Mapping"
|
||||
button gives you, but automatic when nothing was hand-edited.
|
||||
carries the slicer's own saved pick for this exact printer
|
||||
(extra_data.slicer_ams_mapping, written by a VP with "Save AMS
|
||||
mapping" on), the queue item should inherit it — the same
|
||||
exact-physical-spool reuse the "Mapping" button gives you, but
|
||||
automatic when nothing was hand-edited.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
@@ -281,6 +284,54 @@ class TestPrintQueueAPI:
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] == [5, -1, 2, -1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""A saved mapping's tray IDs only mean something relative to the
|
||||
printer they were resolved against. Reprinting the same archive on a
|
||||
*different* printer must not inherit it — tray 5 on printer A can
|
||||
hold a completely different spool than tray 5 on printer B.
|
||||
"""
|
||||
origin_printer = await printer_factory()
|
||||
other_printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": other_printer.id,
|
||||
"archive_id": archive.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""A model-based item (no fixed printer_id) can't know in advance
|
||||
which printer the scheduler will pick, so a saved mapping resolved
|
||||
against one specific printer must never be inherited here either.
|
||||
"""
|
||||
origin_printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"target_model": "X1C",
|
||||
"archive_id": archive.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
|
||||
@@ -291,7 +342,9 @@ class TestPrintQueueAPI:
|
||||
pick — the fallback only fires when the caller sent nothing at all.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
|
||||
@@ -1880,6 +1880,7 @@ class TestVirtualPrinterInstance:
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
save_ams_mapping=False,
|
||||
target_printer_id=7,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
@@ -1913,9 +1914,8 @@ class TestVirtualPrinterInstance:
|
||||
assert item.ams_mapping is not None
|
||||
assert _json.loads(item.ams_mapping) == [4, -1, 12, -1]
|
||||
|
||||
# Toggle is off: the archive's print_data must NOT carry the mapping.
|
||||
print_data = mock_archive_print.await_args.kwargs["print_data"]
|
||||
assert print_data["ams_mapping"] is None
|
||||
# Toggle is off: archive_print must not be told to persist a mapping.
|
||||
assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
|
||||
@@ -1948,6 +1948,7 @@ class TestVirtualPrinterInstance:
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
save_ams_mapping=True,
|
||||
target_printer_id=7,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
@@ -1979,8 +1980,75 @@ class TestVirtualPrinterInstance:
|
||||
assert len(added_items) == 1
|
||||
assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
|
||||
|
||||
print_data = mock_archive_print.await_args.kwargs["print_data"]
|
||||
assert print_data["ams_mapping"] == [4, -1, 12, -1]
|
||||
assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, -1, 12, -1]
|
||||
assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_ignores_ams_mapping_for_model_based_vp(self, tmp_path):
|
||||
"""A model-based VP (`target_printer_id=None`, dispatched later by the
|
||||
scheduler to whichever printer matches) has no MQTT bridge to a real
|
||||
printer, so the slicer has no live AMS layout to resolve tray IDs
|
||||
against. Whatever it sends must be ignored on both the queue item and
|
||||
the archive — trusting it would dispatch the eventually-chosen
|
||||
printer onto a tray resolved against nothing (#2700 review).
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(side_effect=added_items.append)
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=451,
|
||||
name="AMSMappingModelBased",
|
||||
mode="queue",
|
||||
model="X1C",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800451",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
save_ams_mapping=True,
|
||||
target_printer_id=None,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
await inst.on_print_command(
|
||||
file_path.name,
|
||||
{"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
|
||||
)
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
) as mock_archive_print,
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
assert added_items[0].ams_mapping is None
|
||||
assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
|
||||
assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
|
||||
@@ -2010,6 +2078,7 @@ class TestVirtualPrinterInstance:
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
save_ams_mapping=True,
|
||||
target_printer_id=7,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
@@ -2040,8 +2109,7 @@ class TestVirtualPrinterInstance:
|
||||
|
||||
assert len(added_items) == 1
|
||||
assert added_items[0].ams_mapping is None
|
||||
print_data = mock_archive_print.await_args.kwargs["print_data"]
|
||||
assert print_data["ams_mapping"] is None
|
||||
assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
|
||||
@@ -2228,6 +2296,205 @@ class TestVirtualPrinterInstance:
|
||||
# Recent-queue tracking dict is cleared after the patch.
|
||||
assert file_path.name not in inst._recent_queue_items
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_print_command_late_mqtt_retroactively_stamps_archive_ams_mapping(self, tmp_path):
|
||||
"""Same #1780-round-3 race as the test above, but for `ams_mapping`
|
||||
specifically: the archive was already created (with no
|
||||
`slicer_ams_mapping`, since the slicer's pick hadn't arrived yet)
|
||||
before this late MQTT lands. With the toggle on and a fixed target
|
||||
printer, the archive must be retroactively patched too — otherwise a
|
||||
reprint from this archive would never see the badge or the Mapping
|
||||
button, even though the queue item itself dispatches correctly.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items: list = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(
|
||||
side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
|
||||
)
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_archive_row = MagicMock()
|
||||
mock_archive_row.id = 55
|
||||
mock_archive_row.extra_data = None
|
||||
|
||||
position_max_result = MagicMock()
|
||||
position_max_result.scalar = MagicMock(return_value=None)
|
||||
select_pending_result = MagicMock()
|
||||
# (queue_item_id, archive_id) — matches the id set by db.add above.
|
||||
select_pending_result.all = MagicMock(return_value=[(101, 55)])
|
||||
update_result = MagicMock()
|
||||
select_archives_result = MagicMock()
|
||||
select_archives_result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row])))
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[position_max_result, select_pending_result, update_result, select_archives_result]
|
||||
)
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=98,
|
||||
name="LateMQTTArchivePatch",
|
||||
mode="queue",
|
||||
model="X1C",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800098",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
save_ams_mapping=True,
|
||||
target_printer_id=7,
|
||||
)
|
||||
inst._mqtt = MagicMock()
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 55
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
|
||||
0.05,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
assert added_items[0].ams_mapping is None # MQTT was never received in time
|
||||
|
||||
# MQTT project_file arrives late, carrying the slicer's AMS pick.
|
||||
await inst.on_print_command(
|
||||
file_path.name,
|
||||
{
|
||||
"command": "project_file",
|
||||
"file": file_path.name,
|
||||
"ams_mapping": [4, -1, 12, -1],
|
||||
},
|
||||
)
|
||||
|
||||
# 3rd execute() is the queue-item UPDATE; 4th is the archive SELECT.
|
||||
update_call = mock_db.execute.await_args_list[2]
|
||||
compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
|
||||
assert _json.loads(dict(compiled.params)["ams_mapping"]) == [4, -1, 12, -1]
|
||||
|
||||
assert mock_archive_row.extra_data == {
|
||||
"slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_print_command_late_mqtt_skips_archive_patch_for_model_based_vp(self, tmp_path):
|
||||
"""The archive patch above must not fire for a model-based VP
|
||||
(`target_printer_id=None`) either — same rationale as the immediate
|
||||
path: no fixed printer means no live AMS layout to have resolved the
|
||||
late mapping against.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items: list = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(
|
||||
side_effect=lambda item: (added_items.append(item), setattr(item, "id", 200 + len(added_items)))[0]
|
||||
)
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
position_max_result = MagicMock()
|
||||
position_max_result.scalar = MagicMock(return_value=None)
|
||||
select_pending_result = MagicMock()
|
||||
select_pending_result.all = MagicMock(return_value=[(201, 65)])
|
||||
update_result = MagicMock()
|
||||
# No 4th execute() expected — the archive patch must be skipped
|
||||
# entirely, so only 3 calls should ever happen.
|
||||
mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=97,
|
||||
name="LateMQTTModelBased",
|
||||
mode="queue",
|
||||
model="X1C",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800097",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
save_ams_mapping=True,
|
||||
target_printer_id=None,
|
||||
)
|
||||
inst._mqtt = MagicMock()
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 65
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
|
||||
0.05,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
await inst.on_print_command(
|
||||
file_path.name,
|
||||
{
|
||||
"command": "project_file",
|
||||
"file": file_path.name,
|
||||
"ams_mapping": [4, -1, 12, -1],
|
||||
# An unrelated field so `patch` isn't empty and the UPDATE
|
||||
# actually runs — isolates the assertion to "ams_mapping was
|
||||
# excluded" rather than "nothing happened at all".
|
||||
"timelapse": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 3rd execute() is the queue-item UPDATE — ams_mapping must be absent
|
||||
# from it. No 4th execute() (the archive SELECT) should follow.
|
||||
assert mock_db.execute.await_count == 3
|
||||
update_call = mock_db.execute.await_args_list[2]
|
||||
compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
|
||||
assert "ams_mapping" not in dict(compiled.params)
|
||||
assert dict(compiled.params)["timelapse"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
|
||||
"""The actual race-window scenario: wait_for times out, then MQTT
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
|
||||
@@ -38,34 +38,46 @@ export function FilamentMapping({
|
||||
// normal auto-match, without touching any *other* manual picks the user
|
||||
// made by hand.
|
||||
const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
|
||||
// Which slot IDs the ON branch below actually wrote into manualMappings —
|
||||
// so OFF can undo exactly those and leave any *other* manual pick the user
|
||||
// made by hand (before or after pressing the button) untouched.
|
||||
const appliedSlotIdsRef = useRef<number[]>([]);
|
||||
|
||||
// Reset the toggle whenever the saved mapping it would apply changes — a
|
||||
// different plate selection or a different archive entirely. Without this
|
||||
// the button can read ON (green) from a previous archive/plate even though
|
||||
// it was never pressed against the mapping currently in scope.
|
||||
// different printer, plate selection, or archive entirely. Without this
|
||||
// the button can read ON (green) from a previous printer/archive/plate
|
||||
// even though it was never pressed against the mapping currently in scope.
|
||||
useEffect(() => {
|
||||
setUsingArchiveMapping(false);
|
||||
}, [archiveAmsMapping, plateLabel]);
|
||||
appliedSlotIdsRef.current = [];
|
||||
}, [archiveAmsMapping, plateLabel, printerId]);
|
||||
|
||||
const toggleArchiveMapping = () => {
|
||||
if (!archiveAmsMapping || !filamentReqs?.filaments) return;
|
||||
if (usingArchiveMapping) {
|
||||
const next = { ...manualMappings };
|
||||
for (const req of filamentReqs.filaments) {
|
||||
if (req.slot_id > 0) delete next[req.slot_id];
|
||||
for (const slotId of appliedSlotIdsRef.current) {
|
||||
delete next[slotId];
|
||||
}
|
||||
onManualMappingChange(next);
|
||||
appliedSlotIdsRef.current = [];
|
||||
setUsingArchiveMapping(false);
|
||||
return;
|
||||
}
|
||||
const next = { ...manualMappings };
|
||||
const appliedSlotIds: number[] = [];
|
||||
for (const req of filamentReqs.filaments) {
|
||||
const idx = req.slot_id - 1;
|
||||
// A negative value (e.g. the external spool sentinel) means the
|
||||
// slicer didn't resolve this filament to an AMS tray — leave that
|
||||
// slot's existing auto-match/manual pick alone rather than clearing it.
|
||||
if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
|
||||
next[req.slot_id] = archiveAmsMapping[idx];
|
||||
appliedSlotIds.push(req.slot_id);
|
||||
}
|
||||
}
|
||||
onManualMappingChange(next);
|
||||
appliedSlotIdsRef.current = appliedSlotIds;
|
||||
setUsingArchiveMapping(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -327,10 +327,21 @@ export function PrintModal({
|
||||
|
||||
// The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
|
||||
// mapping" virtual-printer setting) — undefined for library files or
|
||||
// archives that predate the feature / had it off at print time.
|
||||
const archiveSlicerAmsMapping = !isLibraryFile
|
||||
? (archiveDetails?.extra_data?.slicer_ams_mapping as number[] | undefined)
|
||||
// archives that predate the feature / had it off at print time. Stored as
|
||||
// `{mapping, printer_id}`: the tray IDs are only meaningful against the
|
||||
// specific printer's AMS layout they were resolved from, so only surface
|
||||
// them when reprinting on that same printer — a different printer's slot 3
|
||||
// can hold a completely different spool.
|
||||
const archiveSlicerAmsMappingRaw = !isLibraryFile
|
||||
? (archiveDetails?.extra_data?.slicer_ams_mapping as
|
||||
| { mapping?: number[]; printer_id?: number }
|
||||
| undefined)
|
||||
: undefined;
|
||||
const archiveSlicerAmsMapping =
|
||||
archiveSlicerAmsMappingRaw?.printer_id === effectivePrinterId &&
|
||||
Array.isArray(archiveSlicerAmsMappingRaw.mapping)
|
||||
? archiveSlicerAmsMappingRaw.mapping
|
||||
: undefined;
|
||||
|
||||
// Fetch plates for archives
|
||||
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
|
||||
@@ -1441,6 +1452,7 @@ export function PrintModal({
|
||||
onForceColorMatchChange={(slotId, value) =>
|
||||
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
|
||||
}
|
||||
archiveAmsMapping={archiveSlicerAmsMapping}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1113,7 +1113,13 @@ function ArchiveCard({
|
||||
{/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting) —
|
||||
a reprint reuses this exact physical spool instead of re-deriving
|
||||
one from the file's type/color. */}
|
||||
{Array.isArray((archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping) && (
|
||||
{Array.isArray(
|
||||
(
|
||||
(archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping as
|
||||
| { mapping?: unknown }
|
||||
| undefined
|
||||
)?.mapping,
|
||||
) && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-green text-xs mb-3" title={t('archives.card.slicerAmsMappingTooltip')}>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t('archives.card.slicerAmsMapping')}
|
||||
|
||||
Reference in New Issue
Block a user