Просмотр исходного кода

fix(virtual-printer): queued prints keep the slicer's options, not another printer's nozzles

- Nozzle offset calibration from the slicer (or the default print
  option) is stored on VP queue items, in both intake paths; it always
  fell back to auto.
- AMS on/off from the slicer is stored when the options arrive in
  time, as it already was when they arrived late.
- On a rack model (H2C), the slicer's nozzle positions are kept only
  by a VP bound to one printer; an "Any" VP leaves them to the
  dispatch, which resolves them against the rack of the printer it
  picks.
- A late slicer AMS mapping is not stamped onto a plate it cannot
  reach.
- A job the scheduler moves to another printer, or releases to the
  pool, drops everything worked out for the old printer
  (drop_printer_resolved), not only its AMS mapping.
maziggy 5 дней назад
Родитель
Сommit
dc72a0daea

+ 6 - 0
CHANGELOG.md

@@ -164,6 +164,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Prints queued through a virtual printer keep the slicer's nozzle choices** —
+  - **Nozzle offset calibration** from the slicer, or the default from Settings → Workflow, is used. It always ran on "Auto".
+  - **H2C rack nozzles** picked in the slicer are kept only by a virtual printer bound to one printer. A virtual printer for any H2C sent those positions to whichever H2C took the job, whatever that printer's rack held; the dispatch now picks them from that printer's rack.
+  - **A job the queue moves to another printer**, or releases back to the pool, no longer takes the old printer's nozzle positions along.
+  - **AMS on or off** from the slicer is used when the slicer's options arrive in time; it was used only when they arrived late.
+  - **A slicer AMS mapping that arrives late** is no longer put on a plate of a multi-plate send that it cannot reach; that plate gets a computed mapping, as when the options arrive in time.
 - **Settings that were not saved or not used** —
 - **Settings that were not saved or not used** —
   - **Max copies per run** (Workflow) and the Docker compose directory used in the update instructions are saved. They changed on screen and were never sent, so a 100-copy pipeline run stayed refused at the old limit of 50.
   - **Max copies per run** (Workflow) and the Docker compose directory used in the update instructions are saved. They changed on screen and were never sent, so a 100-copy pipeline run stayed refused at the old limit of 50.
   - **Default printer** (General) is now selected in the print dialog when it opens, unless the file was sliced for another model or the job goes to a model rather than a printer. The setting was saved and never used.
   - **Default printer** (General) is now selected in the print dialog when it opens, unless the file was sliced for another model or the job goes to a model rather than a printer. The setting was saved and never used.

+ 5 - 3
backend/app/services/print_scheduler.py

@@ -64,6 +64,7 @@ from backend.app.services.printer_manager import (
     supports_drying,
     supports_drying,
     supports_drying_while_printing,
     supports_drying_while_printing,
 )
 )
+from backend.app.services.queue_item_printer import drop_printer_resolved
 from backend.app.services.smart_plug_manager import publish_plug_state, smart_plug_manager
 from backend.app.services.smart_plug_manager import publish_plug_state, smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
 from backend.app.services.spool_assignment_notifications import (
     _global_tray_from_assignment,
     _global_tray_from_assignment,
@@ -7976,8 +7977,9 @@ class PrintScheduler:
         )
         )
         self._short_hold_checked_at.pop(item.id, None)
         self._short_hold_checked_at.pop(item.id, None)
         item.printer_id = match_id
         item.printer_id = match_id
-        # Resolved against the printer it leaves (#3239).
-        item.ams_mapping = None
+        # Resolved against the printer it leaves (#3239): the tray mapping and
+        # the nozzle positions.
+        drop_printer_resolved(item)
         item.manual_start = False
         item.manual_start = False
         item.filament_short = False
         item.filament_short = False
         item.waiting_reason = None
         item.waiting_reason = None
@@ -8145,7 +8147,7 @@ class PrintScheduler:
             # has the filament, since an unresolved slot is deliberately not a
             # has the filament, since an unresolved slot is deliberately not a
             # conflict worth recomputing over.
             # conflict worth recomputing over.
             item.printer_id = None
             item.printer_id = None
-            item.ams_mapping = None
+            drop_printer_resolved(item)
         await db.commit()
         await db.commit()
 
 
         job_name = await self._get_job_name(db, item)
         job_name = await self._get_job_name(db, item)

+ 73 - 11
backend/app/services/virtual_printer/manager.py

@@ -28,6 +28,7 @@ from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
+from backend.app.utils.printer_models import is_nozzle_rack_model
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from backend.app.services.printer_manager import PrinterManager
     from backend.app.services.printer_manager import PrinterManager
@@ -131,13 +132,13 @@ _SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
 _RECENT_QUEUE_ITEM_TTL = 30.0
 _RECENT_QUEUE_ITEM_TTL = 30.0
 
 
 # BambuStudio's tri-state calibration options (bed_leveling / flow_cali /
 # BambuStudio's tri-state calibration options (bed_leveling / flow_cali /
-# nozzle_offset_cali) travel on the project_file command as a bool plus an int
-# companion — off=0, on=1, auto=2 (getValueInt parity). The int carries the full
-# state; the bool is true only for "on".
+# nozzle_offset_cali) travel on the project_file command as an int — off=0,
+# on=1, auto=2 (getValueInt parity). Bed levelling and flow calibration add a
+# bool that is true only for "on"; nozzle offset calibration is the int alone.
 _TRISTATE_INT = {0: "off", 1: "on", 2: "auto"}
 _TRISTATE_INT = {0: "off", 1: "on", 2: "auto"}
 
 
 
 
-def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str | None:
+def _tristate_from_slicer(data: dict, bool_field: str | None, int_field: str) -> str | None:
     """Reconstruct off/on/auto from a captured slicer project_file dict.
     """Reconstruct off/on/auto from a captured slicer project_file dict.
 
 
     Prefer the int companion (auto_bed_leveling / extrude_cali_flag / etc.) which
     Prefer the int companion (auto_bed_leveling / extrude_cali_flag / etc.) which
@@ -151,7 +152,7 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
             resolved = None
             resolved = None
         if resolved is not None:
         if resolved is not None:
             return resolved
             return resolved
-    if bool_field in data:
+    if bool_field is not None and bool_field in data:
         return "on" if bool(data[bool_field]) else "off"
         return "on" if bool(data[bool_field]) else "off"
     return None
     return None
 
 
@@ -325,6 +326,9 @@ class VirtualPrinterInstance:
         # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
         # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
         # queue-add.
         # queue-add.
         self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
         self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
+        # Per entry above: the highest slot id each queued plate prints, so a
+        # late slicer AMS mapping is stamped only onto plates it reaches.
+        self._recent_queue_item_reach: dict[str, dict[int, int]] = {}
 
 
         # Per-instance services
         # Per-instance services
         self._proxy: SlicerProxyManager | None = None
         self._proxy: SlicerProxyManager | None = None
@@ -411,6 +415,18 @@ class VirtualPrinterInstance:
             # IDLE reset was wrong — staying at FINISH is the designed
             # IDLE reset was wrong — staying at FINISH is the designed
             # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
             # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
 
 
+    def _keeps_slicer_nozzle_mapping(self) -> bool:
+        """Whether the slicer's ``nozzle_mapping`` is stored on the queue item.
+
+        On a rack model (H2C) it holds physical rack positions, which name the
+        rack of the printer the slicer was looking at: kept only when this VP
+        targets that one printer. A VP for any printer of the model leaves it
+        empty, and the dispatch resolves the positions against the rack of the
+        printer it picks. Elsewhere it holds extruder indices, which are the
+        same on every printer of the model.
+        """
+        return self.target_printer_id is not None or not is_nozzle_rack_model(self.model)
+
     def _target_is_dual_nozzle(self) -> bool:
     def _target_is_dual_nozzle(self) -> bool:
         """Whether the target printer has two nozzles, which decides the global
         """Whether the target printer has two nozzles, which decides the global
         tray of an external spool in the slicer's mapping (#3237).
         tray of an external spool in the slicer's mapping (#3237).
@@ -511,11 +527,13 @@ class VirtualPrinterInstance:
         committed for this filename and patches in the slicer's
         committed for this filename and patches in the slicer's
         ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
         ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
         while the items are still ``pending`` (scheduler hasn't dispatched
         while the items are still ``pending`` (scheduler hasn't dispatched
-        them yet).
+        them yet). The two mappings pass the same gates as in
+        ``_add_to_print_queue``.
         """
         """
         if not self._session_factory:
         if not self._session_factory:
             return
             return
         entry = self._recent_queue_items.get(stash_key)
         entry = self._recent_queue_items.get(stash_key)
+        plate_reach = self._recent_queue_item_reach.pop(stash_key, {})
         if entry is None:
         if entry is None:
             return
             return
         queue_item_ids, committed_at = entry
         queue_item_ids, committed_at = entry
@@ -534,6 +552,7 @@ class VirtualPrinterInstance:
         for bool_field, int_field, column in (
         for bool_field, int_field, column in (
             ("bed_leveling", "auto_bed_leveling", "bed_levelling"),
             ("bed_leveling", "auto_bed_leveling", "bed_levelling"),
             ("flow_cali", "extrude_cali_flag", "flow_cali"),
             ("flow_cali", "extrude_cali_flag", "flow_cali"),
+            (None, "nozzle_offset_cali", "nozzle_offset_cali"),
         ):
         ):
             resolved = _tristate_from_slicer(data, bool_field, int_field)
             resolved = _tristate_from_slicer(data, bool_field, int_field)
             if resolved is not None:
             if resolved is not None:
@@ -548,7 +567,8 @@ class VirtualPrinterInstance:
             if mqtt_field in data:
             if mqtt_field in data:
                 patch[column] = bool(data[mqtt_field])
                 patch[column] = bool(data[mqtt_field])
 
 
-        raw = data.get("nozzle_mapping")
+        # Same gate as in `_add_to_print_queue`.
+        raw = data.get("nozzle_mapping") if self._keeps_slicer_nozzle_mapping() else None
         if raw is not None:
         if raw is not None:
             if isinstance(raw, str):
             if isinstance(raw, str):
                 try:
                 try:
@@ -610,8 +630,29 @@ class VirtualPrinterInstance:
                 if not eligible_ids:
                 if not eligible_ids:
                     self._recent_queue_items.pop(stash_key, None)
                     self._recent_queue_items.pop(stash_key, None)
                     return
                     return
-                if patch:
-                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+                # As in `_add_to_print_queue`: a mapping shorter than a plate's
+                # highest slot id cannot address that plate's own slots, so that
+                # plate is left to the scheduler's computed mapping.
+                short_ids: list[int] = []
+                if "ams_mapping" in patch:
+                    reach = len(json.loads(patch["ams_mapping"]))
+                    short_ids = [i for i in eligible_ids if plate_reach.get(i, 0) > reach]
+                    if short_ids:
+                        logger.warning(
+                            "[VP %s] Late slicer ams_mapping has %d entries, too few for queue item(s) %s; "
+                            "leaving those to the scheduler's mapping.",
+                            self.name,
+                            reach,
+                            short_ids,
+                        )
+                reached_ids = [i for i in eligible_ids if i not in short_ids]
+                if patch and reached_ids:
+                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(reached_ids)).values(**patch))
+                short_patch = {k: v for k, v in patch.items() if k != "ams_mapping"}
+                if short_ids and short_patch:
+                    await db.execute(
+                        update(PrintQueueItem).where(PrintQueueItem.id.in_(short_ids)).values(**short_patch)
+                    )
 
 
                 # The archive was already created (with no slicer_ams_mapping)
                 # The archive was already created (with no slicer_ams_mapping)
                 # before this late MQTT arrived — see
                 # before this late MQTT arrived — see
@@ -915,7 +956,7 @@ class VirtualPrinterInstance:
                         return bool(slicer_opts[field_mqtt])
                         return bool(slicer_opts[field_mqtt])
                     return settings_default
                     return settings_default
 
 
-                def _slicer_tristate(bool_field: str, int_field: str, settings_default: str) -> str:
+                def _slicer_tristate(bool_field: str | None, int_field: str, settings_default: str) -> str:
                     """Slicer's tri-state (off/on/auto) if present, else the default."""
                     """Slicer's tri-state (off/on/auto) if present, else the default."""
                     if slicer_opts is not None:
                     if slicer_opts is not None:
                         resolved = _tristate_from_slicer(slicer_opts, bool_field, int_field)
                         resolved = _tristate_from_slicer(slicer_opts, bool_field, int_field)
@@ -936,6 +977,11 @@ class VirtualPrinterInstance:
                     "extrude_cali_flag",
                     "extrude_cali_flag",
                     _tristate_setting(await get_setting(db, "default_flow_cali"), "auto"),
                     _tristate_setting(await get_setting(db, "default_flow_cali"), "auto"),
                 )
                 )
+                nozzle_offset_cali = _slicer_tristate(
+                    None,
+                    "nozzle_offset_cali",
+                    _tristate_setting(await get_setting(db, "default_nozzle_offset_cali"), "auto"),
+                )
                 vibration_cali = _slicer_or(
                 vibration_cali = _slicer_or(
                     "vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
                     "vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
                 )
                 )
@@ -943,6 +989,8 @@ class VirtualPrinterInstance:
                     "layer_inspect", _bool_setting(await get_setting(db, "default_layer_inspect"), False)
                     "layer_inspect", _bool_setting(await get_setting(db, "default_layer_inspect"), False)
                 )
                 )
                 timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
                 timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
+                # No setting for it: the column default (on) when the slicer is silent.
+                use_ams = _slicer_or("use_ams", True)
 
 
                 # "Ask for Outcome" is a default print option like the ones
                 # "Ask for Outcome" is a default print option like the ones
                 # above. A plate sent here from Bambu Studio is also one of the
                 # above. A plate sent here from Bambu Studio is also one of the
@@ -965,8 +1013,13 @@ class VirtualPrinterInstance:
                 # in the original fix but BambuStudio never actually sends it
                 # in the original fix but BambuStudio never actually sends it
                 # — verified via wire capture on H2C — so only `nozzle_mapping`
                 # — verified via wire capture on H2C — so only `nozzle_mapping`
                 # is forwarded now.)
                 # is forwarded now.)
+                #
+                # On a rack model only for a VP that targets one printer: the
+                # positions name that printer's rack. An "Any <model>" VP leaves
+                # them to the dispatch, which resolves them against the rack of
+                # the printer it picks. See `_keeps_slicer_nozzle_mapping`.
                 nozzle_mapping_json: str | None = None
                 nozzle_mapping_json: str | None = None
-                if slicer_opts is not None:
+                if slicer_opts is not None and self._keeps_slicer_nozzle_mapping():
                     raw = slicer_opts.get("nozzle_mapping")
                     raw = slicer_opts.get("nozzle_mapping")
                     if raw is not None:
                     if raw is not None:
                         # BambuStudio's NetworkAgent embeds this as parsed
                         # BambuStudio's NetworkAgent embeds this as parsed
@@ -1109,6 +1162,7 @@ class VirtualPrinterInstance:
                     # force_color_match=True when the per-VP setting is on, so
                     # force_color_match=True when the per-VP setting is on, so
                     # upgraders keep the old behaviour by default.
                     # upgraders keep the old behaviour by default.
                     queue_item_ids: list[int] = []
                     queue_item_ids: list[int] = []
+                    queue_item_reach: dict[int, int] = {}
                     for offset, plate_id in enumerate(plate_ids, start=1):
                     for offset, plate_id in enumerate(plate_ids, start=1):
                         required_filament_types_json: str | None = None
                         required_filament_types_json: str | None = None
                         filament_overrides_json: str | None = None
                         filament_overrides_json: str | None = None
@@ -1152,6 +1206,7 @@ class VirtualPrinterInstance:
                         # back to a computed mapping for that plate instead
                         # back to a computed mapping for that plate instead
                         # (#2700 review).
                         # (#2700 review).
                         plate_ams_mapping_json = queue_ams_mapping_json
                         plate_ams_mapping_json = queue_ams_mapping_json
+                        plate_max_slot = max((r.get("slot_id") or 0) for r in requirements) if requirements else 0
                         if queue_ams_mapping is not None and requirements:
                         if queue_ams_mapping is not None and requirements:
                             max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
                             max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
                             if max_slot_id > len(queue_ams_mapping):
                             if max_slot_id > len(queue_ams_mapping):
@@ -1177,9 +1232,11 @@ class VirtualPrinterInstance:
                             filament_overrides=filament_overrides_json,
                             filament_overrides=filament_overrides_json,
                             bed_levelling=bed_levelling,
                             bed_levelling=bed_levelling,
                             flow_cali=flow_cali,
                             flow_cali=flow_cali,
+                            nozzle_offset_cali=nozzle_offset_cali,
                             vibration_cali=vibration_cali,
                             vibration_cali=vibration_cali,
                             layer_inspect=layer_inspect,
                             layer_inspect=layer_inspect,
                             timelapse=timelapse,
                             timelapse=timelapse,
+                            use_ams=use_ams,
                             confirm_outcome=confirm_outcome,
                             confirm_outcome=confirm_outcome,
                             # Per-VP opt-in for auto-print G-code injection (#1516).
                             # Per-VP opt-in for auto-print G-code injection (#1516).
                             # Default off; when on, the scheduler still no-ops unless
                             # Default off; when on, the scheduler still no-ops unless
@@ -1198,6 +1255,7 @@ class VirtualPrinterInstance:
                         db.add(queue_item)
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         await db.flush()  # populate queue_item.id before logging
                         queue_item_ids.append(queue_item.id)
                         queue_item_ids.append(queue_item.id)
+                        queue_item_reach[queue_item.id] = plate_max_slot
                     await db.commit()
                     await db.commit()
                     # Track the freshly-committed queue items so
                     # Track the freshly-committed queue items so
                     # `on_print_command` can retroactively stamp slicer-side
                     # `on_print_command` can retroactively stamp slicer-side
@@ -1210,6 +1268,10 @@ class VirtualPrinterInstance:
                     cutoff = now - _RECENT_QUEUE_ITEM_TTL
                     cutoff = now - _RECENT_QUEUE_ITEM_TTL
                     self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
                     self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
                     self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
                     self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
+                    self._recent_queue_item_reach = {
+                        k: v for k, v in self._recent_queue_item_reach.items() if k in self._recent_queue_items
+                    }
+                    self._recent_queue_item_reach[file_path.name] = queue_item_reach
                     # Last-chance check: MQTT for this filename could have
                     # Last-chance check: MQTT for this filename could have
                     # arrived during ANY await between the initial pop and
                     # arrived during ANY await between the initial pop and
                     # now — wait_for itself, archive_print, db.flush,
                     # now — wait_for itself, archive_print, db.flush,

+ 10 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1737,6 +1737,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
         mock_session_factory.return_value = mock_session_ctx
 
 
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=42,
             vp_id=42,
             name="H2CRack",
             name="H2CRack",
             mode="queue",
             mode="queue",
@@ -2301,6 +2303,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
         mock_session_factory.return_value = mock_session_ctx
 
 
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=44,
             vp_id=44,
             name="H2CMultiPlate",
             name="H2CMultiPlate",
             mode="queue",
             mode="queue",
@@ -2391,6 +2395,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
         mock_session_factory.return_value = mock_session_ctx
 
 
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=99,
             vp_id=99,
             name="LateMQTT",
             name="LateMQTT",
             mode="queue",
             mode="queue",
@@ -2879,6 +2885,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
         mock_session_factory.return_value = mock_session_ctx
 
 
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=96,
             vp_id=96,
             name="RaceCommitYield",
             name="RaceCommitYield",
             mode="queue",
             mode="queue",
@@ -2971,6 +2979,8 @@ class TestVirtualPrinterInstance:
         mock_session_factory.return_value = mock_session_ctx
         mock_session_factory.return_value = mock_session_ctx
 
 
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
+            # The slicer's nozzle positions are kept only for a VP bound to one printer.
+            target_printer_id=7,
             vp_id=98,
             vp_id=98,
             name="LateMQTTDispatched",
             name="LateMQTTDispatched",
             mode="queue",
             mode="queue",

+ 233 - 0
backend/tests/unit/services/test_vp_queue_intake_d2.py

@@ -0,0 +1,233 @@
+"""What a VP in queue mode takes from the slicer onto the queue item.
+
+Nozzle offset calibration was never read, so every VP-queued print ran with
+"auto" whatever Bambu Studio or the default print options said. The slicer's
+nozzle positions were stored for an "Any <model>" VP too, and the dispatch
+sends a stored mapping as it is instead of resolving it against the rack of the
+printer it picked.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+
+def _session(added: list, execute_results=None):
+    db = AsyncMock()
+    db.add = MagicMock(side_effect=lambda item: (added.append(item), setattr(item, "id", 100 + len(added)))[0])
+    db.flush = AsyncMock()
+    db.commit = AsyncMock()
+    if execute_results is not None:
+        db.execute = AsyncMock(side_effect=execute_results)
+    ctx = AsyncMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    return MagicMock(return_value=ctx), db
+
+
+def _vp(tmp_path, factory, **kwargs):
+    return VirtualPrinterInstance(
+        vp_id=77,
+        name="Intake",
+        mode="queue",
+        model="O1C2",
+        access_code="12345678",
+        serial_suffix="391800077",
+        base_dir=tmp_path,
+        session_factory=factory,
+        **kwargs,
+    )
+
+
+async def _enqueue(inst, tmp_path, slicer_opts: dict | None, settings: dict | None = None):
+    file_path = tmp_path / "test.3mf"
+    file_path.write_bytes(b"fake3mf")
+    if slicer_opts is not None:
+        await inst.on_print_command(file_path.name, {"command": "project_file", **slicer_opts})
+    archive = MagicMock(id=1, print_name="test")
+    values = settings or {}
+
+    async def _setting(_db, key):
+        return values.get(key)
+
+    with (
+        patch("backend.app.api.routes.settings.get_setting", side_effect=_setting),
+        patch(
+            "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=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")
+    return file_path
+
+
+class TestNozzleOffsetCalibration:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("wire", "stored"), [(0, "off"), (1, "on"), (2, "auto")])
+    async def test_the_slicers_choice_is_kept(self, tmp_path, wire, stored):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"nozzle_offset_cali": wire}, {"default_nozzle_offset_cali": "on"})
+
+        assert added[0].nozzle_offset_cali == stored
+
+    @pytest.mark.asyncio
+    async def test_without_a_slicer_choice_the_default_applies(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"timelapse": True}, {"default_nozzle_offset_cali": "off"})
+
+        assert added[0].nozzle_offset_cali == "off"
+
+    @pytest.mark.asyncio
+    async def test_a_late_slicer_choice_is_patched_on(self, tmp_path):
+        added: list = []
+        position_max = MagicMock(scalar=MagicMock(return_value=None))
+        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        factory, db = _session(added, [position_max, pending, MagicMock()])
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+        inst._mqtt = MagicMock()
+
+        file_path = await _enqueue(inst, tmp_path, None)
+        await inst.on_print_command(
+            file_path.name, {"command": "project_file", "file": file_path.name, "nozzle_offset_cali": 0}
+        )
+
+        params = dict(db.execute.await_args_list[2].args[0].compile().params)
+        assert params["nozzle_offset_cali"] == "off"
+
+
+class TestNozzlePositionsNeedOnePrinter:
+    @pytest.mark.asyncio
+    async def test_a_vp_bound_to_a_printer_keeps_them(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
+
+        assert json.loads(added[0].nozzle_mapping) == [16, -1]
+
+    @pytest.mark.asyncio
+    async def test_an_any_model_vp_leaves_them_to_the_dispatch(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory)
+
+        await _enqueue(inst, tmp_path, {"nozzle_mapping": [16, -1]})
+
+        assert added[0].target_model is not None
+        assert added[0].nozzle_mapping is None
+
+    @pytest.mark.asyncio
+    async def test_an_any_model_vp_does_not_patch_them_on_late(self, tmp_path):
+        added: list = []
+        position_max = MagicMock(scalar=MagicMock(return_value=None))
+        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        factory, db = _session(added, [position_max, pending, MagicMock()])
+        inst = _vp(tmp_path, factory)
+        inst._mqtt = MagicMock()
+
+        file_path = await _enqueue(inst, tmp_path, None)
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "file": file_path.name, "nozzle_mapping": [16, -1], "timelapse": True},
+        )
+
+        params = dict(db.execute.await_args_list[2].args[0].compile().params)
+        assert params["timelapse"] is True
+        assert "nozzle_mapping" not in params
+
+
+class TestAnyH2DKeepsNozzleIndices:
+    @pytest.mark.asyncio
+    async def test_extruder_indices_are_the_same_on_every_printer(self, tmp_path):
+        """Off a rack model the mapping names extruders, not a printer's rack."""
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory)
+        inst.model = "O1D"  # H2D
+
+        await _enqueue(inst, tmp_path, {"nozzle_mapping": [0, 1]})
+
+        assert json.loads(added[0].nozzle_mapping) == [0, 1]
+
+
+class TestAmsOnOff:
+    @pytest.mark.asyncio
+    async def test_the_slicers_choice_is_kept_when_it_arrives_in_time(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"use_ams": False})
+
+        assert added[0].use_ams is False
+
+    @pytest.mark.asyncio
+    async def test_on_when_the_slicer_does_not_say(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        await _enqueue(inst, tmp_path, {"timelapse": True})
+
+        assert added[0].use_ams is True
+
+
+class TestLateAmsMappingPerPlate:
+    @pytest.mark.asyncio
+    async def test_each_queued_plate_records_the_highest_slot_it_prints(self, tmp_path):
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory, target_printer_id=7)
+
+        with patch(
+            "backend.app.services.filament_requirements.extract_filament_requirements",
+            return_value=[{"slot_id": 1, "type": "PLA"}, {"slot_id": 4, "type": "PLA"}],
+        ):
+            file_path = await _enqueue(inst, tmp_path, {"timelapse": True})
+
+        assert inst._recent_queue_item_reach[file_path.name] == {added[0].id: 4}
+
+    @pytest.mark.asyncio
+    async def test_a_plate_the_mapping_cannot_reach_keeps_the_rest_of_the_patch(self, tmp_path):
+        import time
+
+        added: list = []
+        pending = MagicMock(all=MagicMock(return_value=[(101, None), (102, None)]))
+        factory, db = _session(added, [pending, MagicMock(), MagicMock()])
+        inst = VirtualPrinterInstance(
+            vp_id=78,
+            name="LatePlates",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800078",
+            base_dir=tmp_path,
+            session_factory=factory,
+            save_ams_mapping=True,
+            target_printer_id=7,
+        )
+        inst._recent_queue_items["test.3mf"] = ([101, 102], time.monotonic())
+        inst._recent_queue_item_reach["test.3mf"] = {101: 2, 102: 6}
+
+        await inst.on_print_command(
+            "test.3mf",
+            {"command": "project_file", "file": "test.3mf", "ams_mapping": [0, 1, -1], "timelapse": True},
+        )
+
+        reached, short = (call.args[0] for call in db.execute.await_args_list[1:3])
+        reached_params = dict(reached.compile().params)
+        short_params = dict(short.compile().params)
+        assert json.loads(reached_params["ams_mapping"]) == [0, 1, -1]
+        assert [101] in reached_params.values()
+        assert "ams_mapping" not in short_params and short_params["timelapse"] is True
+        assert [102] in short_params.values()

+ 7 - 1
backend/tests/unit/test_ams_mapping_per_printer_2799.py

@@ -407,12 +407,18 @@ class TestHoldOnTheModelBasedPath:
         scheduler = _scheduler(P2S_5_TRAYS)
         scheduler = _scheduler(P2S_5_TRAYS)
         scheduler._get_job_name = AsyncMock(return_value="body1")
         scheduler._get_job_name = AsyncMock(return_value="body1")
         scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
         scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
-        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+        item = _item(
+            ams_mapping=json.dumps([0, -1, -1]),
+            nozzle_mapping=json.dumps([16, -1]),
+            nozzle_rack_choice=json.dumps({"0": 16}),
+        )
 
 
         blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
         blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
 
 
         assert blocked is True
         assert blocked is True
         assert item.printer_id is None
         assert item.printer_id is None
+        # So do the nozzle positions, which name that printer's rack.
+        assert item.nozzle_mapping is None and item.nozzle_rack_choice is None
         # The mapping goes with the assignment: its tray IDs were resolved
         # The mapping goes with the assignment: its tray IDs were resolved
         # against the printer just released, so leaving it would hold the job
         # against the printer just released, so leaving it would hold the job
         # again on the next printer even when that one has the filament.
         # again on the next printer even when that one has the filament.

+ 11 - 1
backend/tests/unit/test_scheduler_model_filament_short_3137.py

@@ -254,7 +254,16 @@ class TestAssignment:
 class TestHeldJob:
 class TestHeldJob:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
     async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
-        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        item_id = await _add_item(
+            queue_db,
+            printer_id=1,
+            manual_start=True,
+            filament_short=True,
+            # Worked out for printer 1; printer 2 works out its own.
+            ams_mapping="[0]",
+            nozzle_mapping="[16]",
+            nozzle_rack_choice='{"0": 16}',
+        )
         launched = MagicMock()
         launched = MagicMock()
 
 
         await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
         await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
@@ -265,6 +274,7 @@ class TestHeldJob:
         assert item.printer_id == 2
         assert item.printer_id == 2
         assert item.manual_start is False
         assert item.manual_start is False
         assert item.filament_short is False
         assert item.filament_short is False
+        assert (item.ams_mapping, item.nozzle_mapping, item.nozzle_rack_choice) == (None, None, None)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_stays_held_while_every_other_printer_is_short(self, queue_db):
     async def test_stays_held_while_every_other_printer_is_short(self, queue_db):