Jelajahi Sumber

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 bulan lalu
induk
melakukan
166e9f9ef2

File diff ditekan karena terlalu besar
+ 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:
         except json.JSONDecodeError:
             filament_overrides_parsed = None
             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
     nozzle_mapping_parsed = None
     if item.nozzle_mapping:
     if item.nozzle_mapping:
         try:
         try:
             nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
             nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             nozzle_mapping_parsed = None
             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
     # Create response with parsed ams_mapping
     item_dict = {
     item_dict = {
@@ -203,7 +197,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "gcode_injection": item.gcode_injection,
         "gcode_injection": item.gcode_injection,
         # H2C rack-swap nozzle pick (#1780)
         # H2C rack-swap nozzle pick (#1780)
         "nozzle_mapping": nozzle_mapping_parsed,
         "nozzle_mapping": nozzle_mapping_parsed,
-        "nozzles_info": nozzles_info_parsed,
     }
     }
     response = PrintQueueItemResponse(**item_dict)
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
     if item.archive:
@@ -1035,8 +1028,6 @@ async def update_queue_item(
         update_data["nozzle_mapping"] = (
         update_data["nozzle_mapping"] = (
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
             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():
     for field, value in update_data.items():
         setattr(item, field, value)
         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")
         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
     # 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 nozzle_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info 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
     # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
     # project_file MQTT command for rack-swap-capable models (O1C2 today)
     # 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)
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: 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
     nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     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
     nozzle_mapping: list[int] | None = None
-    nozzles_info: list[dict] | None = None
 
 
 
 
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
@@ -174,7 +171,6 @@ class PrintQueueItemResponse(BaseModel):
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # uploads from BambuStudio.
     # uploads from BambuStudio.
     nozzle_mapping: list[int] | None = None
     nozzle_mapping: list[int] | None = None
-    nozzles_info: list[dict] | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

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

@@ -3502,7 +3502,6 @@ class BambuMQTTClient:
         use_ams: bool = True,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
         nozzle_offset_cali: bool = False,
         nozzle_mapping: str | None = None,
         nozzle_mapping: str | None = None,
-        nozzles_info: str | None = None,
     ):
     ):
         """Start a print job on the printer.
         """Start a print job on the printer.
 
 
@@ -3528,9 +3527,6 @@ class BambuMQTTClient:
                 firmware honours the user's slicer pick instead of falling
                 firmware honours the user's slicer pick instead of falling
                 back to "last matching nozzle" auto-pick. Silently ignored
                 back to "last matching nozzle" auto-pick. Silently ignored
                 on single-nozzle printers.
                 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:
         if self._client and self.state.connected:
             # Bambu print command format — matches Bambu Studio's format.
             # Bambu print command format — matches Bambu Studio's format.
@@ -3690,32 +3686,24 @@ class BambuMQTTClient:
 
 
             # H2C dual-nozzle-rack slicer-pick preservation (#1780).
             # H2C dual-nozzle-rack slicer-pick preservation (#1780).
             # `nozzle_mapping` carries per-filament physical nozzle position
             # `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))
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             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)
         effective_timelapse = bool(item.timelapse)
 
 
         # Start the print with AMS mapping, plate_id and print options.
         # 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(
         started = printer_manager.start_print(
             item.printer_id,
             item.printer_id,
             remote_filename,
             remote_filename,
@@ -2342,7 +2342,6 @@ class PrintScheduler:
             use_ams=item.use_ams,
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping,
             nozzle_mapping=item.nozzle_mapping,
-            nozzles_info=item.nozzles_info,
         )
         )
 
 
         if started:
         if started:

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

@@ -566,15 +566,13 @@ class PrinterManager:
         use_ams: bool = True,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
         nozzle_offset_cali: bool = False,
         nozzle_mapping: str | None = None,
         nozzle_mapping: str | None = None,
-        nozzles_info: str | None = None,
     ) -> bool:
     ) -> bool:
         """Start a print on a connected printer.
         """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]
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
         logger.info(
@@ -598,7 +596,6 @@ class PrinterManager:
                 use_ams=use_ams,
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_mapping=nozzle_mapping,
                 nozzle_mapping=nozzle_mapping,
-                nozzles_info=nozzles_info,
             )
             )
         return False
         return False
 
 

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

@@ -289,9 +289,10 @@ class VirtualPrinterInstance:
         """Handle print command from MQTT.
         """Handle print command from MQTT.
 
 
         Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
         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
         Only queue mode consumes the capture; archive / review / proxy
         modes ignore the print command, so we skip the stash there to keep
         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
         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
         moment after the synthetic project_file ack — for every non-proxy
         mode — so the slicer's "Downloading" UI releases on the slicer's
         mode — so the slicer's "Downloading" UI releases on the slicer's
         FTP-first-then-MQTT send order.
         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)
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         mode = normalize_vp_mode(self.mode)
         mode = normalize_vp_mode(self.mode)
@@ -308,6 +319,12 @@ class VirtualPrinterInstance:
             self._schedule_finish_release(filename)
             self._schedule_finish_release(filename)
         if mode != VP_MODE_QUEUE:
         if mode != VP_MODE_QUEUE:
             return
             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
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
         # slicer sends project_file for a filename whose FTP upload was
         # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
         # 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)
                 logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
             except StopIteration:
             except StopIteration:
                 pass
                 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:
         if event:
             event.set()
             event.set()
 
 
@@ -525,6 +542,18 @@ class VirtualPrinterInstance:
                 slicer_opts = None
                 slicer_opts = None
             finally:
             finally:
                 self._slicer_print_options_events.pop(file_path.name, None)
                 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:
         try:
             import json
             import json
@@ -575,46 +604,38 @@ class VirtualPrinterInstance:
 
 
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # BambuStudio's project_file MQTT command for rack-swap models
                 # 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
                 nozzle_mapping_json: str | None = None
-                nozzles_info_json: str | None = None
                 if slicer_opts is not 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):
                         if isinstance(raw, str):
                             try:
                             try:
                                 raw = json.loads(raw)
                                 raw = json.loads(raw)
                             except json.JSONDecodeError:
                             except json.JSONDecodeError:
                                 logger.warning(
                                 logger.warning(
-                                    "[VP %s] Slicer %s is unparseable JSON, dropping: %r",
+                                    "[VP %s] Slicer nozzle_mapping is unparseable JSON, dropping: %r",
                                     self.name,
                                     self.name,
-                                    src_key,
                                     raw,
                                     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)
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                 archive = await service.archive_print(
@@ -723,7 +744,6 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
                             nozzle_mapping=nozzle_mapping_json,
-                            nozzles_info=nozzles_info_json,
                         )
                         )
                         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

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

@@ -1293,6 +1293,14 @@ class SimpleMQTTServer:
                     file_3mf = print_data.get("file", filename)
                     file_3mf = print_data.get("file", filename)
                     await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
                     await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
                     if self.on_print_command:
                     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)
                         await self._notify_print_command(filename, print_data)
                     handled_locally = True
                     handled_locally = True
 
 

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

@@ -5082,14 +5082,17 @@ class TestStartPrintRecordsDispatchedPlate:
 
 
 
 
 class TestStartPrintNozzleMappingDispatch:
 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
     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
     @pytest.fixture
@@ -5111,29 +5114,23 @@ class TestStartPrintNozzleMappingDispatch:
         call_args = mqtt_client._client.publish.call_args
         call_args = mqtt_client._client.publish.call_args
         return json.loads(call_args[0][1])["print"]
         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."""
         verbatim onto the dispatched project_file command."""
         mqtt_client._is_dual_nozzle = True
         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(
         mqtt_client.start_print(
             "test.3mf",
             "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)
         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):
     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)."""
         carry a stale capture from before a model change)."""
         mqtt_client._is_dual_nozzle = False
         mqtt_client._is_dual_nozzle = False
         mqtt_client.model = "P1S"  # single-nozzle
         mqtt_client.model = "P1S"  # single-nozzle
@@ -5141,41 +5138,22 @@ class TestStartPrintNozzleMappingDispatch:
         mqtt_client.start_print(
         mqtt_client.start_print(
             "test.3mf",
             "test.3mf",
             nozzle_mapping=json.dumps([16, 0, 19]),
             nozzle_mapping=json.dumps([16, 0, 19]),
-            nozzles_info=json.dumps([{"id": 1}]),
         )
         )
 
 
         cmd = self._published_print_cmd(mqtt_client)
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
         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
         """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."""
         nozzle model that has no rack to disambiguate against anyway."""
         mqtt_client._is_dual_nozzle = True
         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)
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
         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):
     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
         """Invalid JSON on the queue item must NOT block the dispatch. Log a
@@ -5189,7 +5167,6 @@ class TestStartPrintNozzleMappingDispatch:
             result = mqtt_client.start_print(
             result = mqtt_client.start_print(
                 "test.3mf",
                 "test.3mf",
                 nozzle_mapping="not valid json {",
                 nozzle_mapping="not valid json {",
-                nozzles_info=None,
             )
             )
 
 
         assert result is True  # dispatch still proceeded
         assert result is True  # dispatch still proceeded
@@ -5197,17 +5174,16 @@ class TestStartPrintNozzleMappingDispatch:
         assert "nozzle_mapping" not in cmd
         assert "nozzle_mapping" not in cmd
         assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records)
         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
         """An empty-string column value (legacy data, or a NOT NULL DB
         recovery shim) must behave the same as NULL — no injection, no
         recovery shim) must behave the same as NULL — no injection, no
         parse error log."""
         parse error log."""
         mqtt_client._is_dual_nozzle = True
         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)
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
 
 
 
 
 class TestFilamentTrackSwitchDetection:
 class TestFilamentTrackSwitchDetection:

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

@@ -379,7 +379,6 @@ class TestPrinterManager:
             use_ams=True,
             use_ams=True,
             nozzle_offset_cali=False,
             nozzle_offset_cali=False,
             nozzle_mapping=None,
             nozzle_mapping=None,
-            nozzles_info=None,
         )
         )
         assert result is True
         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)
         assert all(q.manual_start for q in added_items)
 
 
     @pytest.mark.asyncio
     @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
         """#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
         import json as _json
 
 
@@ -1617,18 +1617,16 @@ class TestVirtualPrinterInstance:
         file_path.write_bytes(b"fake3mf")
         file_path.write_bytes(b"fake3mf")
 
 
         # Pre-populate as if BS's project_file arrived. Wire shape matches
         # 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(
         await inst.on_print_command(
             file_path.name,
             file_path.name,
             {
             {
                 "command": "project_file",
                 "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
         assert len(added_items) == 1
         item = added_items[0]
         item = added_items[0]
         assert item.nozzle_mapping is not None
         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
     @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
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
 
@@ -1719,13 +1713,12 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         item = added_items[0]
         item = added_items[0]
         assert item.nozzle_mapping is None
         assert item.nozzle_mapping is None
-        assert item.nozzles_info is None
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
     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
         """#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.
         filament_overrides, etc.
         """
         """
         import json as _json
         import json as _json
@@ -1767,7 +1760,6 @@ class TestVirtualPrinterInstance:
             {
             {
                 "command": "project_file",
                 "command": "project_file",
                 "nozzle_mapping": [16, 0],
                 "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
         assert len(added_items) == 3
         for item in added_items:
         for item in added_items:
             assert _json.loads(item.nozzle_mapping) == [16, 0]
             assert _json.loads(item.nozzle_mapping) == [16, 0]
-            assert _json.loads(item.nozzles_info)[0]["flowSize"] == "High Flow"
 
 
 
 
 class TestVirtualPrinterManager:
 class TestVirtualPrinterManager:
@@ -3491,3 +3482,109 @@ class TestSSDPProxyName:
         rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
         rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
 
 
         assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
         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()

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini