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

fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field

  First-attempt fix (d196cfc5) was wrong about the cause. Real root,
  traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
  support bundle:

  mqtt_server.py:1296 was passing the slicer's bare subtask_name
  (e.g. "Model_Name") into on_print_command, which stashed under
  that key. _add_to_print_queue looked up under file_path.name
  (the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
  two strings never matched. pop returned None, the 2s wait fired
  against a key the stash side never signaled, every captured
  slicer field silently fell back to settings defaults.

  Affected EVERY Bambu Studio "Send" upload across EVERY model —
  not just H2C nozzle_mapping. bed_leveling / flow_cali /
  vibration_cali / layer_inspect / timelapse from the original
  #1403 capture have been silently ignored since BambuStudio
  started splitting subtask_name (bare) from file (with extension).

  Unit tests passed because fixtures called on_print_command with
  file_path.name directly, bypassing the broken caller.

  Fix in manager.py::on_print_command: derive
  stash_key = data.get("file") or filename and use it for both
  _slicer_print_options and the event lookup. filename
  (subtask_name) still flows unchanged to _schedule_finish_release
  — push_status echoes it back as gcode_file / subtask_name and
  the slicer matches against its own subtask_name there, so
  re-routing that path was a separate regression I caught and
  reverted mid-audit.

  Also: nozzles_info field was a wrong guess in d196cfc5 —
  BambuStudio never sends it (confirmed via wire capture). Drop
  the capture, dispatch, schema, kwarg, and route paths. DB
  column stays nullable so old rows still load; nothing reads
  or writes it.

  Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
  options after the 2s wait, including the looked-up key and the
  actual cache keys present. Future stash/lookup mismatches will
  be obvious from a log line instead of needing a wire capture.

  Behaviour change worth flagging: users on Bambu Studio whose
  slicer-side bed-leveling / flow-cali / vibration-cali /
  layer-inspect / timelapse differ from Bambuddy's
  default-workflow settings will see their slicer choices
  honored now instead of silently overridden. Restores #1403's
  original intent.
maziggy 2 месяцев назад
Родитель
Сommit
166e9f9ef2

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 4 - 13
backend/app/api/routes/print_queue.py

@@ -142,22 +142,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             filament_overrides_parsed = None
 
-    # Parse nozzle_mapping + nozzles_info from JSON string (#1780 — H2C rack
-    # slicer-pick preservation). Both are nullable opaque JSON blobs stored
-    # verbatim from BambuStudio's project_file; surface them parsed for the
-    # response model and any future "edit print → nozzle" UI.
+    # Parse nozzle_mapping from JSON string (#1780 — H2C rack slicer-pick
+    # preservation). Nullable opaque JSON blob stored verbatim from
+    # BambuStudio's project_file; surface it parsed for the response model
+    # and any future "edit print → nozzle" UI.
     nozzle_mapping_parsed = None
     if item.nozzle_mapping:
         try:
             nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
         except json.JSONDecodeError:
             nozzle_mapping_parsed = None
-    nozzles_info_parsed = None
-    if item.nozzles_info:
-        try:
-            nozzles_info_parsed = json.loads(item.nozzles_info)
-        except json.JSONDecodeError:
-            nozzles_info_parsed = None
 
     # Create response with parsed ams_mapping
     item_dict = {
@@ -203,7 +197,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "gcode_injection": item.gcode_injection,
         # H2C rack-swap nozzle pick (#1780)
         "nozzle_mapping": nozzle_mapping_parsed,
-        "nozzles_info": nozzles_info_parsed,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -1035,8 +1028,6 @@ async def update_queue_item(
         update_data["nozzle_mapping"] = (
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
-    if "nozzles_info" in update_data:
-        update_data["nozzles_info"] = json.dumps(update_data["nozzles_info"]) if update_data["nozzles_info"] else None
 
     for field, value in update_data.items():
         setattr(item, field, value)

+ 8 - 5
backend/app/core/database.py

@@ -969,11 +969,14 @@ async def run_migrations(conn):
         await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
 
     # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
-    # slicer-pick preservation (#1780). Opaque JSON-string columns carrying
-    # BambuStudio's per-filament physical nozzle position IDs and the
-    # per-extruder rack metadata, forwarded straight from the VP intake to
-    # the dispatcher's project_file MQTT command. NULL on every other model.
-    # Nullable TEXT — no Postgres / SQLite divergence here.
+    # slicer-pick preservation (#1780). Opaque JSON-string column carrying
+    # BambuStudio's per-filament physical nozzle position IDs, forwarded
+    # straight from the VP intake to the dispatcher's project_file MQTT
+    # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite
+    # divergence here. `nozzles_info` shipped in the original #1780 attempt
+    # but BambuStudio never actually sends it (verified via wire capture on
+    # H2C, see CHANGELOG 0.2.5b1) — the column stays nullable so old rows
+    # still load; nothing reads or writes to it anymore.
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
 

+ 7 - 6
backend/app/models/print_queue.py

@@ -67,12 +67,13 @@ class PrintQueueItem(Base):
 
     # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
     # project_file MQTT command for rack-swap-capable models (O1C2 today)
-    # carries per-filament physical nozzle position IDs in `nozzle_mapping`
-    # and per-extruder rack metadata in `nozzles_info`. Both are forwarded
-    # verbatim through the queue and replayed by the dispatcher so the
-    # firmware honours the user's pick instead of falling back to
-    # "last matching nozzle type" auto-pick. Stored as opaque JSON strings
-    # (list[int] and list[dict] respectively); NULL on every other model.
+    # carries per-filament physical nozzle position IDs in `nozzle_mapping`,
+    # forwarded verbatim through the queue and replayed by the dispatcher so
+    # the firmware honours the user's pick instead of falling back to
+    # "last matching nozzle type" auto-pick. Stored as opaque JSON string
+    # (list[int]); NULL on every other model. `nozzles_info` is a deprecated
+    # column from the original #1780 attempt — kept nullable so old rows still
+    # load; never written to or read from.
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
 

+ 3 - 7
backend/app/schemas/print_queue.py

@@ -82,13 +82,10 @@ class PrintQueueItemUpdate(BaseModel):
     nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     gcode_injection: bool | None = None
-    # H2C dual-nozzle-rack slicer pick (#1780). Both fields are opaque
-    # JSON-encoded structures BambuStudio sends in its project_file MQTT
-    # body; sent back to the printer verbatim on dispatch. list[int] for
-    # nozzle_mapping (per-filament physical nozzle position IDs), list[dict]
-    # for nozzles_info (per-extruder rack metadata).
+    # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
+    # physical nozzle position IDs from BambuStudio's project_file MQTT
+    # body; sent back to the printer verbatim on dispatch.
     nozzle_mapping: list[int] | None = None
-    nozzles_info: list[dict] | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -174,7 +171,6 @@ class PrintQueueItemResponse(BaseModel):
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # uploads from BambuStudio.
     nozzle_mapping: list[int] | None = None
-    nozzles_info: list[dict] | None = None
 
     class Config:
         from_attributes = True

+ 18 - 30
backend/app/services/bambu_mqtt.py

@@ -3502,7 +3502,6 @@ class BambuMQTTClient:
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
         nozzle_mapping: str | None = None,
-        nozzles_info: str | None = None,
     ):
         """Start a print job on the printer.
 
@@ -3528,9 +3527,6 @@ class BambuMQTTClient:
                 firmware honours the user's slicer pick instead of falling
                 back to "last matching nozzle" auto-pick. Silently ignored
                 on single-nozzle printers.
-            nozzles_info: Opaque JSON string for the per-extruder rack
-                metadata BambuStudio's project_file carries alongside
-                `nozzle_mapping` (#1780). Same dual-nozzle gating.
         """
         if self._client and self.state.connected:
             # Bambu print command format — matches Bambu Studio's format.
@@ -3690,32 +3686,24 @@ class BambuMQTTClient:
 
             # H2C dual-nozzle-rack slicer-pick preservation (#1780).
             # `nozzle_mapping` carries per-filament physical nozzle position
-            # IDs (`list[int]`), `nozzles_info` carries per-extruder rack
-            # metadata (`list[dict]`). Both are JSON-string-encoded when
-            # they leave the queue item; parse here so the wire ships
-            # arrays/objects, matching BambuStudio's project_file shape.
-            # Gate by `is_dual_nozzle` defensively — single-nozzle firmwares
-            # would ignore them but we err on the side of not emitting
-            # unrecognised fields. A parse failure is logged but never
-            # blocks the dispatch — the firmware will fall back to its
-            # auto-pick path, which is the pre-fix behaviour.
-            if is_dual_nozzle:
-                for src_str, json_key in (
-                    (nozzle_mapping, "nozzle_mapping"),
-                    (nozzles_info, "nozzles_info"),
-                ):
-                    if not src_str:
-                        continue
-                    try:
-                        command["print"][json_key] = json.loads(src_str)
-                    except json.JSONDecodeError:
-                        logger.warning(
-                            "[%s] Invalid %s JSON on dispatch, omitting from "
-                            "project_file (firmware will auto-pick): %r",
-                            self.serial_number,
-                            json_key,
-                            src_str,
-                        )
+            # IDs (`list[int]`), JSON-string-encoded when it leaves the queue
+            # item; parse here so the wire ships an array, matching
+            # BambuStudio's project_file shape. Gate by `is_dual_nozzle`
+            # defensively — single-nozzle firmwares would ignore the field
+            # but we err on the side of not emitting unrecognised fields. A
+            # parse failure is logged but never blocks the dispatch — the
+            # firmware will fall back to its auto-pick path, which is the
+            # pre-fix behaviour.
+            if is_dual_nozzle and nozzle_mapping:
+                try:
+                    command["print"]["nozzle_mapping"] = json.loads(nozzle_mapping)
+                except json.JSONDecodeError:
+                    logger.warning(
+                        "[%s] Invalid nozzle_mapping JSON on dispatch, omitting from "
+                        "project_file (firmware will auto-pick): %r",
+                        self.serial_number,
+                        nozzle_mapping,
+                    )
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)

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

@@ -2325,10 +2325,10 @@ class PrintScheduler:
         effective_timelapse = bool(item.timelapse)
 
         # Start the print with AMS mapping, plate_id and print options.
-        # nozzle_mapping / nozzles_info ride through verbatim — JSON strings
-        # captured from Bambu Studio's project_file on VP intake (#1780); the
-        # MQTT layer parses + injects them only for dual-nozzle models so a
-        # null on every other model is a transparent pass-through.
+        # nozzle_mapping rides through verbatim — JSON string captured from
+        # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
+        # parses + injects it only for dual-nozzle models so a null on every
+        # other model is a transparent pass-through.
         started = printer_manager.start_print(
             item.printer_id,
             remote_filename,
@@ -2342,7 +2342,6 @@ class PrintScheduler:
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping,
-            nozzles_info=item.nozzles_info,
         )
 
         if started:

+ 4 - 7
backend/app/services/printer_manager.py

@@ -566,15 +566,13 @@ class PrinterManager:
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
         nozzle_mapping: str | None = None,
-        nozzles_info: str | None = None,
     ) -> bool:
         """Start a print on a connected printer.
 
-        ``nozzle_mapping`` and ``nozzles_info`` are opaque JSON strings
-        captured from BambuStudio's project_file MQTT command (H2C rack-swap
-        slicer pick preservation, #1780). They ride through to the MQTT
-        client untouched; the dispatch builder there parses + injects them
-        only on dual-nozzle models.
+        ``nozzle_mapping`` is an opaque JSON string captured from BambuStudio's
+        project_file MQTT command (H2C rack-swap slicer pick preservation,
+        #1780). It rides through to the MQTT client untouched; the dispatch
+        builder there parses + injects it only on dual-nozzle models.
         """
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
@@ -598,7 +596,6 @@ class PrinterManager:
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_mapping=nozzle_mapping,
-                nozzles_info=nozzles_info,
             )
         return False
 

+ 56 - 36
backend/app/services/virtual_printer/manager.py

@@ -289,9 +289,10 @@ class VirtualPrinterInstance:
         """Handle print command from MQTT.
 
         Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
-        `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
-        VP-queue path can inherit them when adding the item to the queue,
-        rather than falling back to the global default settings (#1403).
+        `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`, plus the
+        H2C rack-pick `nozzle_mapping`) so the VP-queue path can inherit them
+        when adding the item to the queue, rather than falling back to the
+        global default settings (#1403, #1780).
         Only queue mode consumes the capture; archive / review / proxy
         modes ignore the print command, so we skip the stash there to keep
         the dict from accumulating one entry per print over the VP's
@@ -301,6 +302,16 @@ class VirtualPrinterInstance:
         moment after the synthetic project_file ack — for every non-proxy
         mode — so the slicer's "Downloading" UI releases on the slicer's
         FTP-first-then-MQTT send order.
+
+        ``filename`` is the slicer's ``subtask_name`` (bare model name, no
+        extension) — used verbatim for `_schedule_finish_release` because
+        push_status echoes it back to the slicer as gcode_file / subtask_name.
+        The queue-side stash key is derived from ``data["file"]`` (the FTP
+        filename with extension) so `_add_to_print_queue`'s
+        ``file_path.name`` lookup matches; falls back to ``filename`` when
+        ``data["file"]`` is absent (legacy slicers / non-3MF uploads).
+        Stash/lookup mismatch was the #1780 root cause — every captured field
+        silently fell back to settings defaults on every Bambu Studio "Send".
         """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         mode = normalize_vp_mode(self.mode)
@@ -308,6 +319,12 @@ class VirtualPrinterInstance:
             self._schedule_finish_release(filename)
         if mode != VP_MODE_QUEUE:
             return
+        # Stash key must match `_add_to_print_queue`'s lookup, which uses
+        # `file_path.name` (FTP filename WITH extension). The slicer's
+        # `subtask_name` (== this method's `filename` arg) is the bare model
+        # name, no extension — using it as the stash key was the #1780 root
+        # cause.
+        stash_key = data.get("file") or filename
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
         # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
@@ -321,8 +338,8 @@ class VirtualPrinterInstance:
                 logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
             except StopIteration:
                 pass
-        self._slicer_print_options[filename] = dict(data)
-        event = self._slicer_print_options_events.get(filename)
+        self._slicer_print_options[stash_key] = dict(data)
+        event = self._slicer_print_options_events.get(stash_key)
         if event:
             event.set()
 
@@ -525,6 +542,18 @@ class VirtualPrinterInstance:
                 slicer_opts = None
             finally:
                 self._slicer_print_options_events.pop(file_path.name, None)
+        # If the cache still misses, queued workflow flags / nozzle pick will
+        # silently fall back to settings defaults. Surface the missed key so a
+        # future stash/lookup mismatch (the #1780 root cause) is obvious in
+        # the log instead of needing a wire capture to diagnose.
+        if slicer_opts is None:
+            logger.debug(
+                "[VP %s] No slicer options cached for %r (cache keys: %s); "
+                "workflow flags + nozzle pick will fall back to settings defaults.",
+                self.name,
+                file_path.name,
+                sorted(self._slicer_print_options.keys()),
+            )
 
         try:
             import json
@@ -575,46 +604,38 @@ class VirtualPrinterInstance:
 
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # BambuStudio's project_file MQTT command for rack-swap models
-                # (O1C2 today) carries:
-                #   `nozzle_mapping` — per-filament array of physical nozzle
-                #     position IDs (`list[int]`).
-                #   `nozzles_info`   — per-extruder rack metadata
-                #     (`list[dict]`, fields: id / type / flowSize / diameter).
-                # Forward both verbatim onto the queue item so the dispatcher
-                # can replay them in its own project_file command. Without
-                # this the H2C firmware falls back to "last matching nozzle"
-                # auto-pick and ignores the user's Bambu Studio choice. Every
-                # other model has these absent from slicer_opts, so the
-                # capture is a transparent no-op there.
+                # (O1C2 today) carries `nozzle_mapping` — a per-filament array
+                # of physical nozzle position IDs (`list[int]`). Forward it
+                # verbatim onto the queue item so the dispatcher can replay it
+                # in its own project_file command. Without this the H2C
+                # firmware falls back to "last matching nozzle" auto-pick and
+                # ignores the user's Bambu Studio choice. Every other model
+                # has it absent from slicer_opts, so the capture is a
+                # transparent no-op there. (`nozzles_info` was also captured
+                # in the original fix but BambuStudio never actually sends it
+                # — verified via wire capture on H2C — so only `nozzle_mapping`
+                # is forwarded now.)
                 nozzle_mapping_json: str | None = None
-                nozzles_info_json: str | None = None
                 if slicer_opts is not None:
-                    for src_key in ("nozzle_mapping", "nozzles_info"):
-                        raw = slicer_opts.get(src_key)
-                        if raw is None:
-                            continue
-                        # BambuStudio's NetworkAgent should embed these as
-                        # parsed JSON in the project_file body (matching the
-                        # ams_mapping / ams_mapping2 shape Bambuddy already
-                        # consumes as list[int] / list[dict]). Accept a
-                        # JSON-encoded string defensively in case any path
-                        # arrives stringified.
+                    raw = slicer_opts.get("nozzle_mapping")
+                    if raw is not None:
+                        # BambuStudio's NetworkAgent embeds this as parsed
+                        # JSON in the project_file body (matching the
+                        # ams_mapping shape Bambuddy already consumes as
+                        # list[int]). Accept a JSON-encoded string defensively
+                        # in case any path arrives stringified.
                         if isinstance(raw, str):
                             try:
                                 raw = json.loads(raw)
                             except json.JSONDecodeError:
                                 logger.warning(
-                                    "[VP %s] Slicer %s is unparseable JSON, dropping: %r",
+                                    "[VP %s] Slicer nozzle_mapping is unparseable JSON, dropping: %r",
                                     self.name,
-                                    src_key,
                                     raw,
                                 )
-                                continue
-                        encoded = json.dumps(raw)
-                        if src_key == "nozzle_mapping":
-                            nozzle_mapping_json = encoded
-                        else:
-                            nozzles_info_json = encoded
+                                raw = None
+                        if raw is not None:
+                            nozzle_mapping_json = json.dumps(raw)
 
                 service = ArchiveService(db)
                 archive = await service.archive_print(
@@ -723,7 +744,6 @@ 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,
-                            nozzles_info=nozzles_info_json,
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging

+ 8 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -1293,6 +1293,14 @@ class SimpleMQTTServer:
                     file_3mf = print_data.get("file", filename)
                     await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
                     if self.on_print_command:
+                        # `filename` is the slicer's `subtask_name` (bare model
+                        # name, no extension). Pass it through verbatim — the
+                        # `_schedule_finish_release` chain echoes it back as
+                        # gcode_file + subtask_name in push_status, and the
+                        # slicer matches against its own subtask_name there.
+                        # The FTP filename (with extension) is in print_data
+                        # under "file" for the queue-stash side to use as its
+                        # own key matching `_add_to_print_queue`'s lookup.
                         await self._notify_print_command(filename, print_data)
                     handled_locally = True
 

+ 22 - 46
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5082,14 +5082,17 @@ class TestStartPrintRecordsDispatchedPlate:
 
 
 class TestStartPrintNozzleMappingDispatch:
-    """H2C dual-nozzle-rack (#1780) — nozzle_mapping + nozzles_info on dispatch.
+    """H2C dual-nozzle-rack (#1780) — nozzle_mapping on dispatch.
 
     BambuStudio's project_file MQTT command for O1C2 carries a per-filament
-    physical nozzle position ID array (`nozzle_mapping`) and a per-extruder
-    rack metadata array (`nozzles_info`). Without forwarding both, the H2C
-    firmware falls back to "last matching nozzle type" auto-pick and ignores
-    the user's slicer choice. Tests pin the gate, the parse, the no-op cases,
-    and the malformed-JSON safety net.
+    physical nozzle position ID array (`nozzle_mapping`). Without forwarding
+    it, the H2C firmware falls back to "last matching nozzle type" auto-pick
+    and ignores the user's slicer choice. Tests pin the gate, the parse, the
+    no-op cases, and the malformed-JSON safety net.
+
+    The original #1780 attempt also captured `nozzles_info` but a wire capture
+    on H2C confirmed BambuStudio never sends that field — the capture/dispatch
+    paths for it were dropped in the same release.
     """
 
     @pytest.fixture
@@ -5111,29 +5114,23 @@ class TestStartPrintNozzleMappingDispatch:
         call_args = mqtt_client._client.publish.call_args
         return json.loads(call_args[0][1])["print"]
 
-    def test_dual_nozzle_includes_nozzle_mapping_and_nozzles_info(self, mqtt_client):
-        """Dual-nozzle + both fields present → parsed JSON arrays injected
+    def test_dual_nozzle_includes_nozzle_mapping(self, mqtt_client):
+        """Dual-nozzle + nozzle_mapping present → parsed JSON array injected
         verbatim onto the dispatched project_file command."""
         mqtt_client._is_dual_nozzle = True
-        nozzles_info = [
-            {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
-            {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
-        ]
 
         mqtt_client.start_print(
             "test.3mf",
-            nozzle_mapping=json.dumps([16, 0, 19]),
-            nozzles_info=json.dumps(nozzles_info),
+            nozzle_mapping=json.dumps([16, -1, -1, 1, -1, -1, -1, -1]),
         )
 
         cmd = self._published_print_cmd(mqtt_client)
-        # Lists, not strings — the wire shape must match BambuStudio's.
-        assert cmd["nozzle_mapping"] == [16, 0, 19]
-        assert cmd["nozzles_info"] == nozzles_info
+        # List, not string — the wire shape must match BambuStudio's.
+        assert cmd["nozzle_mapping"] == [16, -1, -1, 1, -1, -1, -1, -1]
 
     def test_single_nozzle_omits_nozzle_mapping_even_if_set(self, mqtt_client):
-        """A single-nozzle printer must NOT emit the rack fields even if the
-        caller passes them (defense-in-depth — the queue item could legitimately
+        """A single-nozzle printer must NOT emit the rack field even if the
+        caller passes it (defense-in-depth — the queue item could legitimately
         carry a stale capture from before a model change)."""
         mqtt_client._is_dual_nozzle = False
         mqtt_client.model = "P1S"  # single-nozzle
@@ -5141,41 +5138,22 @@ class TestStartPrintNozzleMappingDispatch:
         mqtt_client.start_print(
             "test.3mf",
             nozzle_mapping=json.dumps([16, 0, 19]),
-            nozzles_info=json.dumps([{"id": 1}]),
         )
 
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
 
-    def test_dual_nozzle_no_fields_no_injection(self, mqtt_client):
+    def test_dual_nozzle_no_field_no_injection(self, mqtt_client):
         """Dual-nozzle printer + no slicer pick (NULL on queue item) → command
-        carries no nozzle_mapping / nozzles_info. The firmware then runs its
-        normal auto-pick, which is the pre-fix behaviour for any non-O1C2 dual-
+        carries no nozzle_mapping. The firmware then runs its normal
+        auto-pick, which is the pre-fix behaviour for any non-O1C2 dual-
         nozzle model that has no rack to disambiguate against anyway."""
         mqtt_client._is_dual_nozzle = True
 
-        mqtt_client.start_print("test.3mf", nozzle_mapping=None, nozzles_info=None)
+        mqtt_client.start_print("test.3mf", nozzle_mapping=None)
 
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
-
-    def test_dual_nozzle_partial_only_mapping(self, mqtt_client):
-        """Half-populated case: nozzle_mapping carried but nozzles_info NULL.
-        Forward what we have; firmware tolerates a missing rack metadata
-        field and resolves against its own state."""
-        mqtt_client._is_dual_nozzle = True
-
-        mqtt_client.start_print(
-            "test.3mf",
-            nozzle_mapping=json.dumps([16]),
-            nozzles_info=None,
-        )
-
-        cmd = self._published_print_cmd(mqtt_client)
-        assert cmd["nozzle_mapping"] == [16]
-        assert "nozzles_info" not in cmd
 
     def test_malformed_nozzle_mapping_is_logged_and_omitted(self, mqtt_client, caplog):
         """Invalid JSON on the queue item must NOT block the dispatch. Log a
@@ -5189,7 +5167,6 @@ class TestStartPrintNozzleMappingDispatch:
             result = mqtt_client.start_print(
                 "test.3mf",
                 nozzle_mapping="not valid json {",
-                nozzles_info=None,
             )
 
         assert result is True  # dispatch still proceeded
@@ -5197,17 +5174,16 @@ class TestStartPrintNozzleMappingDispatch:
         assert "nozzle_mapping" not in cmd
         assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records)
 
-    def test_empty_string_fields_are_treated_as_absent(self, mqtt_client):
+    def test_empty_string_field_is_treated_as_absent(self, mqtt_client):
         """An empty-string column value (legacy data, or a NOT NULL DB
         recovery shim) must behave the same as NULL — no injection, no
         parse error log."""
         mqtt_client._is_dual_nozzle = True
 
-        mqtt_client.start_print("test.3mf", nozzle_mapping="", nozzles_info="")
+        mqtt_client.start_print("test.3mf", nozzle_mapping="")
 
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
 
 
 class TestFilamentTrackSwitchDetection:

+ 0 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -379,7 +379,6 @@ class TestPrinterManager:
             use_ams=True,
             nozzle_offset_cali=False,
             nozzle_mapping=None,
-            nozzles_info=None,
         )
         assert result is True
 

+ 126 - 29
backend/tests/unit/services/test_virtual_printer.py

@@ -1580,13 +1580,13 @@ class TestVirtualPrinterInstance:
         assert all(q.manual_start for q in added_items)
 
     @pytest.mark.asyncio
-    async def test_add_to_print_queue_captures_nozzle_mapping_and_nozzles_info(self, tmp_path):
+    async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path):
         """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
-        per-filament physical nozzle position IDs in `nozzle_mapping` and
-        per-extruder rack metadata in `nozzles_info`. VP intake must store
-        both as JSON strings on the queue item so the dispatcher can replay
-        them. Without this the H2C firmware falls back to "last matching
-        nozzle" auto-pick and ignores the user's slicer choice.
+        per-filament physical nozzle position IDs in `nozzle_mapping`. VP
+        intake must store it as a JSON string on the queue item so the
+        dispatcher can replay it. Without this the H2C firmware falls back
+        to "last matching nozzle" auto-pick and ignores the user's slicer
+        choice.
         """
         import json as _json
 
@@ -1617,18 +1617,16 @@ class TestVirtualPrinterInstance:
         file_path.write_bytes(b"fake3mf")
 
         # Pre-populate as if BS's project_file arrived. Wire shape matches
-        # BambuStudio's PrintJob params: nozzle_mapping = array of per-
-        # filament physical nozzle position IDs, nozzles_info = array of
-        # per-extruder rack-side metadata.
+        # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of
+        # per-filament physical nozzle position IDs (verified via H2C wire
+        # capture). The slicer-side `nozzles_info` field that the original
+        # #1780 attempt also looked for was never actually sent — it has
+        # been dropped from the capture path entirely.
         await inst.on_print_command(
             file_path.name,
             {
                 "command": "project_file",
-                "nozzle_mapping": [16, 0, 19],
-                "nozzles_info": [
-                    {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
-                    {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
-                ],
+                "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1],
             },
         )
 
@@ -1653,18 +1651,14 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         item = added_items[0]
         assert item.nozzle_mapping is not None
-        assert _json.loads(item.nozzle_mapping) == [16, 0, 19]
-        assert item.nozzles_info is not None
-        parsed_info = _json.loads(item.nozzles_info)
-        assert parsed_info[0]["flowSize"] == "High Flow"
-        assert parsed_info[1]["flowSize"] == "Standard"
+        assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1]
 
     @pytest.mark.asyncio
-    async def test_add_to_print_queue_no_nozzle_fields_when_slicer_omits(self, tmp_path):
-        """#1780: every model other than O1C2 sends no nozzle_mapping /
-        nozzles_info — the queue item must carry NULL on both, not an empty
-        list. NULL is what the dispatch layer keys off of to skip the
-        injection entirely on non-rack-swap printers.
+    async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path):
+        """#1780: every model other than O1C2 sends no nozzle_mapping — the
+        queue item must carry NULL, not an empty list. NULL is what the
+        dispatch layer keys off of to skip the injection entirely on non-
+        rack-swap printers.
         """
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
@@ -1719,13 +1713,12 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         item = added_items[0]
         assert item.nozzle_mapping is None
-        assert item.nozzles_info is None
 
     @pytest.mark.asyncio
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
         """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
-        same nozzle_mapping / nozzles_info on every plate's queue item, not
-        only the first. Mirrors the per-plate stamping for gcode_injection,
+        same nozzle_mapping on every plate's queue item, not only the first.
+        Mirrors the per-plate stamping for gcode_injection,
         filament_overrides, etc.
         """
         import json as _json
@@ -1767,7 +1760,6 @@ class TestVirtualPrinterInstance:
             {
                 "command": "project_file",
                 "nozzle_mapping": [16, 0],
-                "nozzles_info": [{"id": 1, "flowSize": "High Flow", "diameter": 0.4}],
             },
         )
 
@@ -1792,7 +1784,6 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 3
         for item in added_items:
             assert _json.loads(item.nozzle_mapping) == [16, 0]
-            assert _json.loads(item.nozzles_info)[0]["flowSize"] == "High Flow"
 
 
 class TestVirtualPrinterManager:
@@ -3491,3 +3482,109 @@ class TestSSDPProxyName:
         rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
 
         assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
+
+
+class TestVPProjectFileStashKey:
+    """Regression: `on_print_command` MUST stash slicer options under the
+    FTP filename (`data["file"]`, with extension), NOT under `filename`
+    (the slicer's `subtask_name`, bare).
+
+    #1780 root cause (real bundle, 2026-06-21): BambuStudio sends
+    `subtask_name = "Model_Name"` (bare) and `file = "Model_Name.gcode.3mf"`
+    (with extension). `_add_to_print_queue` looks up the stash under
+    `file_path.name` from the FTP receive side, which always has the
+    extension. If the stash uses `subtask_name`, lookup misses → every
+    captured slicer field (bed_leveling, flow_cali, vibration_cali,
+    layer_inspect, timelapse, nozzle_mapping) silently falls back to
+    settings defaults on every Bambu Studio "Send" upload.
+
+    `filename` (subtask_name) must still flow to `_schedule_finish_release`
+    untouched — push_status echoes it back as gcode_file / subtask_name and
+    the slicer matches against its own local subtask_name there. So
+    `on_print_command` keeps `filename` for state-feedback but derives the
+    stash key from `data["file"]`.
+    """
+
+    @pytest.fixture
+    def instance(self, tmp_path):
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        return VirtualPrinterInstance(
+            vp_id=99,
+            name="StashKeyTest",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="999999999",
+            base_dir=tmp_path,
+        )
+
+    @pytest.mark.asyncio
+    async def test_stash_key_uses_file_field_not_subtask_name(self, instance):
+        """BambuStudio's real wire shape: `subtask_name` ≠ `file`.
+        on_print_command must stash under `data["file"]` so the FTP-side
+        `_add_to_print_queue` lookup matches.
+        """
+        # mqtt_server.py:_handle_publish hands the bare subtask_name as
+        # `filename` and the full print_data body as `data`. The FTP filename
+        # lives in `data["file"]`.
+        await instance.on_print_command(
+            "Filament_Track_Switch_Holder",  # subtask_name (bare)
+            {
+                "command": "project_file",
+                "subtask_name": "Filament_Track_Switch_Holder",
+                "file": "Filament_Track_Switch_Holder.gcode.3mf",
+                "nozzle_mapping": [16, -1, -1, 1],
+            },
+        )
+
+        # Stash MUST be under the FTP filename, not the bare subtask_name.
+        # `_add_to_print_queue` does `_slicer_print_options.pop(file_path.name, None)`
+        # where file_path.name == "Filament_Track_Switch_Holder.gcode.3mf".
+        assert "Filament_Track_Switch_Holder.gcode.3mf" in instance._slicer_print_options
+        assert "Filament_Track_Switch_Holder" not in instance._slicer_print_options
+        # Body must carry nozzle_mapping verbatim.
+        stashed = instance._slicer_print_options["Filament_Track_Switch_Holder.gcode.3mf"]
+        assert stashed["nozzle_mapping"] == [16, -1, -1, 1]
+
+    @pytest.mark.asyncio
+    async def test_stash_key_falls_back_to_filename_when_file_absent(self, instance):
+        """Defensive fallback: a slicer that omits the `file` field entirely
+        (legacy / non-3MF) must fall back to `filename` (subtask_name), not
+        leave the stash unkeyed."""
+        await instance.on_print_command(
+            "BareName",
+            {
+                "command": "project_file",
+                "subtask_name": "BareName",
+                # no "file" field
+            },
+        )
+
+        assert "BareName" in instance._slicer_print_options
+
+    @pytest.mark.asyncio
+    async def test_stash_key_signals_event_under_file_key(self, instance):
+        """`_add_to_print_queue` registers a wait-event under `file_path.name`
+        when the slicer's project_file arrives late. on_print_command must
+        signal THAT event (keyed by the FTP filename), not one keyed by
+        subtask_name — else the waiter times out even though the stash is
+        present and addressable."""
+        import asyncio
+
+        ftp_filename = "Filament_Track_Switch_Holder.gcode.3mf"
+        event = asyncio.Event()
+        instance._slicer_print_options_events[ftp_filename] = event
+
+        await instance.on_print_command(
+            "Filament_Track_Switch_Holder",  # bare subtask_name
+            {
+                "command": "project_file",
+                "subtask_name": "Filament_Track_Switch_Holder",
+                "file": ftp_filename,
+            },
+        )
+
+        # Event keyed by FTP filename must fire even though on_print_command
+        # was called with the bare subtask_name.
+        assert event.is_set()

Некоторые файлы не были показаны из-за большого количества измененных файлов