|
|
@@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
+import json
|
|
|
import logging
|
|
|
import time
|
|
|
from collections.abc import Callable
|
|
|
@@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
|
|
|
return None
|
|
|
|
|
|
|
|
|
+def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
|
|
|
+ """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
|
|
|
+
|
|
|
+ BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
|
|
|
+ live, right before sending — either automatically or via the slicer's
|
|
|
+ manual per-filament AMS-slot assignment dialog — and embeds the result as
|
|
|
+ ``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
|
|
|
+ ID) directly in the MQTT ``project_file`` command. Confirmed by wire
|
|
|
+ capture: the field is present and already in the exact shape
|
|
|
+ ``PrintQueueItem.ams_mapping`` expects.
|
|
|
+
|
|
|
+ The VP-queue path previously never read this — every queued print had the
|
|
|
+ scheduler re-derive a mapping from just the 3MF's static type/color at
|
|
|
+ dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
|
|
|
+ the slicer's already-correct, live-resolved pick. That re-derivation can
|
|
|
+ land on the wrong physical spool whenever the file's type+color match
|
|
|
+ isn't unique (e.g. two spools of the same color) or the file's own
|
|
|
+ filament-slot color wasn't what the user actually intended for that
|
|
|
+ particular print. Capturing it here — mirroring the existing
|
|
|
+ ``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
|
|
|
+ scheduler's "already resolved, don't touch it" branch in
|
|
|
+ ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
|
|
|
+
|
|
|
+ That branch skipping ``_compute_ams_mapping_for_printer`` is also what
|
|
|
+ makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
|
|
|
+ AMS-filament-backup gate (#1766), the inventory-remain overrides and the
|
|
|
+ per-slot force-color overrides all live inside that function. Callers are
|
|
|
+ responsible for the gating — this parser only says what the slicer sent.
|
|
|
+
|
|
|
+ Returns ``None`` when the field is absent, unparsable, or the classic
|
|
|
+ "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
|
|
|
+ fresh live computation.
|
|
|
+ """
|
|
|
+ raw = data.get("ams_mapping")
|
|
|
+ if raw is None:
|
|
|
+ return None
|
|
|
+ if isinstance(raw, str):
|
|
|
+ try:
|
|
|
+ raw = json.loads(raw)
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
|
|
|
+ return None
|
|
|
+ # bool is a subclass of int in Python — isinstance(True, int) is True —
|
|
|
+ # so it must be excluded explicitly, or [True, False] would pass as a
|
|
|
+ # valid mapping.
|
|
|
+ if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
|
|
|
+ return None
|
|
|
+ if all(v < 0 for v in raw):
|
|
|
+ # #2589 sentinel — every slot unresolved. Let the scheduler compute a
|
|
|
+ # fresh mapping from live AMS state instead of trusting this.
|
|
|
+ return None
|
|
|
+ return json.dumps(raw)
|
|
|
+
|
|
|
+
|
|
|
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
|
|
|
"""Get serial number for the given model and suffix."""
|
|
|
prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
|
|
|
@@ -181,6 +236,7 @@ class VirtualPrinterInstance:
|
|
|
target_printer_id: int | None = None,
|
|
|
auto_dispatch: bool = True,
|
|
|
queue_force_color_match: bool = False,
|
|
|
+ save_ams_mapping: bool = False,
|
|
|
gcode_injection: bool = False,
|
|
|
bind_ip: str = "",
|
|
|
remote_interface_ip: str = "",
|
|
|
@@ -204,6 +260,7 @@ class VirtualPrinterInstance:
|
|
|
self.target_printer_id = target_printer_id
|
|
|
self.auto_dispatch = auto_dispatch
|
|
|
self.queue_force_color_match = queue_force_color_match
|
|
|
+ self.save_ams_mapping = save_ams_mapping
|
|
|
self.gcode_injection = gcode_injection
|
|
|
self.bind_ip = bind_ip
|
|
|
self.remote_interface_ip = remote_interface_ip
|
|
|
@@ -416,8 +473,9 @@ class VirtualPrinterInstance:
|
|
|
row was already written with settings defaults. This method runs
|
|
|
on the late MQTT path: it looks up the most recent queue items
|
|
|
committed for this filename and patches in the slicer's
|
|
|
- ``nozzle_mapping`` + workflow flags, but only while the items are
|
|
|
- still ``pending`` (scheduler hasn't dispatched them yet).
|
|
|
+ ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
|
|
|
+ while the items are still ``pending`` (scheduler hasn't dispatched
|
|
|
+ them yet).
|
|
|
"""
|
|
|
if not self._session_factory:
|
|
|
return
|
|
|
@@ -469,12 +527,34 @@ class VirtualPrinterInstance:
|
|
|
if raw is not None:
|
|
|
patch["nozzle_mapping"] = json.dumps(raw)
|
|
|
|
|
|
- if not patch:
|
|
|
+ # Same two gates as the immediate path in `_add_to_print_queue`: a
|
|
|
+ # model-based VP has no live AMS layout for the slicer to have resolved
|
|
|
+ # tray IDs against, and taking the slicer's pick at all is the per-VP
|
|
|
+ # `save_ams_mapping` opt-in (it makes the scheduler skip
|
|
|
+ # `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
|
|
|
+ # #1766 backup gate).
|
|
|
+ ams_mapping_json = (
|
|
|
+ _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
|
|
|
+ if self.target_printer_id is not None and self.save_ams_mapping
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ # `Force color match` still wins for this dispatch — see the same
|
|
|
+ # decision in `_add_to_print_queue`. The archive patch below is
|
|
|
+ # deliberately not gated on it: persisting the pick for later reprints
|
|
|
+ # is exactly what the toggle promises.
|
|
|
+ if ams_mapping_json is not None and not self.queue_force_color_match:
|
|
|
+ patch["ams_mapping"] = ams_mapping_json
|
|
|
+
|
|
|
+ # `ams_mapping_json` alone is enough to keep going even when `patch` is
|
|
|
+ # empty: with `Force color match` on it never reaches the queue item,
|
|
|
+ # but it still has to be written onto the archive below.
|
|
|
+ if not patch and ams_mapping_json is None:
|
|
|
self._recent_queue_items.pop(stash_key, None)
|
|
|
return
|
|
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
|
try:
|
|
|
@@ -482,23 +562,49 @@ class VirtualPrinterInstance:
|
|
|
# Only stamp items still pending; once the scheduler has
|
|
|
# picked the row up we can't safely race the dispatcher.
|
|
|
result = await db.execute(
|
|
|
- select(PrintQueueItem.id).where(
|
|
|
+ select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
|
|
|
PrintQueueItem.id.in_(queue_item_ids),
|
|
|
PrintQueueItem.status == "pending",
|
|
|
)
|
|
|
)
|
|
|
- eligible_ids = [row[0] for row in result.all()]
|
|
|
+ rows = result.all()
|
|
|
+ eligible_ids = [row[0] for row in rows]
|
|
|
if not eligible_ids:
|
|
|
self._recent_queue_items.pop(stash_key, None)
|
|
|
return
|
|
|
- await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
|
|
|
+ if patch:
|
|
|
+ await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
|
|
|
+
|
|
|
+ # The archive was already created (with no slicer_ams_mapping)
|
|
|
+ # before this late MQTT arrived — see
|
|
|
+ # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
|
|
|
+ # too so a reprint later still picks up the slicer's pick, and
|
|
|
+ # the "AMS mapping from slicer" badge reflects reality instead
|
|
|
+ # of staying stuck on the archive's initial (empty) snapshot.
|
|
|
+ # Already gated on `save_ams_mapping` above, and deliberately
|
|
|
+ # NOT on `queue_force_color_match`: that toggle decides how
|
|
|
+ # *this* print is matched, not whether the pick is worth
|
|
|
+ # keeping for a later reprint.
|
|
|
+ if ams_mapping_json is not None:
|
|
|
+ archive_ids = {row[1] for row in rows if row[1] is not None}
|
|
|
+ if archive_ids:
|
|
|
+ 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"] = {
|
|
|
+ "mapping": json.loads(ams_mapping_json),
|
|
|
+ "printer_id": self.target_printer_id,
|
|
|
+ }
|
|
|
+ archive.extra_data = extra
|
|
|
+
|
|
|
await db.commit()
|
|
|
logger.info(
|
|
|
- "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
|
|
|
+ "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
|
|
|
self.name,
|
|
|
stash_key,
|
|
|
sorted(patch.keys()),
|
|
|
eligible_ids,
|
|
|
+ " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
|
|
|
)
|
|
|
except Exception as e:
|
|
|
logger.error(
|
|
|
@@ -834,6 +940,60 @@ class VirtualPrinterInstance:
|
|
|
if raw is not None:
|
|
|
nozzle_mapping_json = json.dumps(raw)
|
|
|
|
|
|
+ # Slicer's own live-resolved AMS-slot pick (see docstring on
|
|
|
+ # `_extract_slicer_ams_mapping_json`). Stamped onto every plate
|
|
|
+ # below, same treatment as nozzle_mapping_json above — when
|
|
|
+ # present it makes `_ensure_ams_mapping` skip its own
|
|
|
+ # type/color re-derivation entirely and dispatch use exactly
|
|
|
+ # the tray the slicer/user picked.
|
|
|
+ #
|
|
|
+ # Two gates, both required:
|
|
|
+ #
|
|
|
+ # 1. This VP must target 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).
|
|
|
+ # 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
|
|
|
+ # the slicer's pick means `_ensure_ams_mapping` returns
|
|
|
+ # early and `_compute_ams_mapping_for_printer` never runs —
|
|
|
+ # and that function is where `prefer_lowest_filament`, its
|
|
|
+ # AMS-filament-backup gate (#1766) and the inventory-remain
|
|
|
+ # overrides live. Honouring the slicer unconditionally would
|
|
|
+ # silently retire all of that for every existing queue-mode
|
|
|
+ # VP on upgrade, so it's opt-in like every other queue-mode
|
|
|
+ # behaviour toggle (#2700 review).
|
|
|
+ #
|
|
|
+ # Either gate failing leaves it unset, and the scheduler's
|
|
|
+ # normal type/color re-derivation runs against whichever
|
|
|
+ # printer actually gets the job.
|
|
|
+ ams_mapping_json: str | None = None
|
|
|
+ if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
|
|
|
+ ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
|
|
|
+
|
|
|
+ # `Force color match` is the user asking Bambuddy to do the
|
|
|
+ # matching strictly, against the printer's live trays. Its only
|
|
|
+ # effect on a fixed-printer item is via the per-slot
|
|
|
+ # `filament_overrides` written below, which are consumed inside
|
|
|
+ # `_compute_ams_mapping_for_printer` — the exact function a
|
|
|
+ # stored mapping skips. So when both toggles are on, the
|
|
|
+ # explicit strictness wins for *this* dispatch and the slicer's
|
|
|
+ # pick is still persisted onto the archive for later reprints,
|
|
|
+ # which is what `Save AMS mapping` actually promises (#2700
|
|
|
+ # review).
|
|
|
+ queue_ams_mapping_json = ams_mapping_json
|
|
|
+ if queue_ams_mapping_json is not None and self.queue_force_color_match:
|
|
|
+ logger.info(
|
|
|
+ "[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
|
|
|
+ "'Force color match' is on, so the scheduler matches against live trays for this print.",
|
|
|
+ self.name,
|
|
|
+ )
|
|
|
+ queue_ams_mapping_json = None
|
|
|
+
|
|
|
+ # Parsed once for the per-plate length check in the loop below.
|
|
|
+ queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
|
|
|
+
|
|
|
service = ArchiveService(db)
|
|
|
archive = await service.archive_print(
|
|
|
printer_id=None,
|
|
|
@@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
|
|
|
"source_ip": source_ip,
|
|
|
},
|
|
|
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. Already gated on the per-VP
|
|
|
+ # `save_ams_mapping` opt-in above. 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 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)
|
|
|
@@ -925,6 +1093,31 @@ class VirtualPrinterInstance:
|
|
|
if overrides:
|
|
|
filament_overrides_json = json.dumps(overrides)
|
|
|
|
|
|
+ # The slicer's mapping is indexed by the 3MF's own
|
|
|
+ # file-global slot ids (position = slot_id - 1), so one
|
|
|
+ # array covers every plate of a multi-plate Send All —
|
|
|
+ # each plate just reads the entries for the slots it
|
|
|
+ # actually prints. What must be checked is that it
|
|
|
+ # reaches that far: a mapping shorter than this plate's
|
|
|
+ # highest slot id can't address the plate's own slots,
|
|
|
+ # and `_ensure_ams_mapping` would keep it anyway
|
|
|
+ # because it only rejects an all-unresolved mapping. Fall
|
|
|
+ # back to a computed mapping for that plate instead
|
|
|
+ # (#2700 review).
|
|
|
+ plate_ams_mapping_json = queue_ams_mapping_json
|
|
|
+ if queue_ams_mapping is not None and requirements:
|
|
|
+ max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
|
|
|
+ if max_slot_id > len(queue_ams_mapping):
|
|
|
+ logger.warning(
|
|
|
+ "[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
|
|
|
+ "dropping it for this plate so the scheduler computes one from live AMS state.",
|
|
|
+ self.name,
|
|
|
+ len(queue_ams_mapping),
|
|
|
+ plate_id,
|
|
|
+ max_slot_id,
|
|
|
+ )
|
|
|
+ plate_ams_mapping_json = None
|
|
|
+
|
|
|
queue_item = PrintQueueItem(
|
|
|
printer_id=self.target_printer_id,
|
|
|
target_model=target_model,
|
|
|
@@ -950,6 +1143,9 @@ class VirtualPrinterInstance:
|
|
|
# the same nozzle pick across plates rather than only the
|
|
|
# first one (mirrors the #1697 / #1188 per-plate loop fix).
|
|
|
nozzle_mapping=nozzle_mapping_json,
|
|
|
+ # Slicer's own live AMS-slot pick, when present —
|
|
|
+ # see `_extract_slicer_ams_mapping_json`.
|
|
|
+ ams_mapping=plate_ams_mapping_json,
|
|
|
)
|
|
|
db.add(queue_item)
|
|
|
await db.flush() # populate queue_item.id before logging
|
|
|
@@ -1547,6 +1743,7 @@ class VirtualPrinterManager:
|
|
|
# instance silently keeps the old value until process
|
|
|
# restart (#1552 follow-up family).
|
|
|
or instance.queue_force_color_match != vp.queue_force_color_match
|
|
|
+ or instance.save_ams_mapping != vp.save_ams_mapping
|
|
|
or instance.gcode_injection != vp.gcode_injection
|
|
|
or proxy_target_changed
|
|
|
)
|
|
|
@@ -1601,6 +1798,7 @@ class VirtualPrinterManager:
|
|
|
target_printer_id=vp.target_printer_id,
|
|
|
auto_dispatch=vp.auto_dispatch,
|
|
|
queue_force_color_match=vp.queue_force_color_match,
|
|
|
+ save_ams_mapping=vp.save_ams_mapping,
|
|
|
gcode_injection=vp.gcode_injection,
|
|
|
bind_ip=vp.bind_ip or "",
|
|
|
remote_interface_ip=vp.remote_interface_ip or "",
|