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.
This commit is contained in:
maziggy
2026-06-21 14:09:05 +02:00
parent 4d16faed76
commit 166e9f9ef2
13 changed files with 261 additions and 186 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -13
View File
@@ -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 # Parse nozzle_mapping from JSON string (#1780 — H2C rack slicer-pick
# slicer-pick preservation). Both are nullable opaque JSON blobs stored # preservation). Nullable opaque JSON blob stored verbatim from
# verbatim from BambuStudio's project_file; surface them parsed for the # BambuStudio's project_file; surface it parsed for the response model
# response model and any future "edit print → nozzle" UI. # 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
View File
@@ -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 # slicer-pick preservation (#1780). Opaque JSON-string column carrying
# BambuStudio's per-filament physical nozzle position IDs and the # BambuStudio's per-filament physical nozzle position IDs, forwarded
# per-extruder rack metadata, forwarded straight from the VP intake to # straight from the VP intake to the dispatcher's project_file MQTT
# the dispatcher's project_file MQTT command. NULL on every other model. # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite
# Nullable TEXT — no Postgres / SQLite divergence here. # 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
View File
@@ -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` # carries per-filament physical nozzle position IDs in `nozzle_mapping`,
# and per-extruder rack metadata in `nozzles_info`. Both are forwarded # forwarded verbatim through the queue and replayed by the dispatcher so
# verbatim through the queue and replayed by the dispatcher so the # the firmware honours the user's pick instead of falling back to
# firmware honours the user's pick instead of falling back to # "last matching nozzle type" auto-pick. Stored as opaque JSON string
# "last matching nozzle type" auto-pick. Stored as opaque JSON strings # (list[int]); NULL on every other model. `nozzles_info` is a deprecated
# (list[int] and list[dict] respectively); NULL on every other model. # 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
View File
@@ -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 # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
# JSON-encoded structures BambuStudio sends in its project_file MQTT # physical nozzle position IDs from BambuStudio's project_file MQTT
# body; sent back to the printer verbatim on dispatch. list[int] for # body; sent back to the printer verbatim on dispatch.
# nozzle_mapping (per-filament physical nozzle position IDs), list[dict]
# for nozzles_info (per-extruder rack metadata).
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
View File
@@ -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 # IDs (`list[int]`), JSON-string-encoded when it leaves the queue
# metadata (`list[dict]`). Both are JSON-string-encoded when # item; parse here so the wire ships an array, matching
# they leave the queue item; parse here so the wire ships # BambuStudio's project_file shape. Gate by `is_dual_nozzle`
# arrays/objects, matching BambuStudio's project_file shape. # defensively — single-nozzle firmwares would ignore the field
# Gate by `is_dual_nozzle` defensively — single-nozzle firmwares # but we err on the side of not emitting unrecognised fields. A
# would ignore them but we err on the side of not emitting # parse failure is logged but never blocks the dispatch — the
# unrecognised fields. A parse failure is logged but never # firmware will fall back to its auto-pick path, which is the
# blocks the dispatch — the firmware will fall back to its # pre-fix behaviour.
# auto-pick path, which is the pre-fix behaviour. if is_dual_nozzle and nozzle_mapping:
if is_dual_nozzle: try:
for src_str, json_key in ( command["print"]["nozzle_mapping"] = json.loads(nozzle_mapping)
(nozzle_mapping, "nozzle_mapping"), except json.JSONDecodeError:
(nozzles_info, "nozzles_info"), logger.warning(
): "[%s] Invalid nozzle_mapping JSON on dispatch, omitting from "
if not src_str: "project_file (firmware will auto-pick): %r",
continue self.serial_number,
try: nozzle_mapping,
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,
)
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
View File
@@ -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 # nozzle_mapping rides through verbatim — JSON string captured from
# captured from Bambu Studio's project_file on VP intake (#1780); the # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
# MQTT layer parses + injects them only for dual-nozzle models so a # parses + injects it only for dual-nozzle models so a null on every
# null on every other model is a transparent pass-through. # 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
View File
@@ -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 ``nozzle_mapping`` is an opaque JSON string captured from BambuStudio's
captured from BambuStudio's project_file MQTT command (H2C rack-swap project_file MQTT command (H2C rack-swap slicer pick preservation,
slicer pick preservation, #1780). They ride through to the MQTT #1780). It rides through to the MQTT client untouched; the dispatch
client untouched; the dispatch builder there parses + injects them builder there parses + injects it only on dual-nozzle models.
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
View File
@@ -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 `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`, plus the
VP-queue path can inherit them when adding the item to the queue, H2C rack-pick `nozzle_mapping`) so the VP-queue path can inherit them
rather than falling back to the global default settings (#1403). 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) self._slicer_print_options[stash_key] = dict(data)
event = self._slicer_print_options_events.get(filename) 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: # (O1C2 today) carries `nozzle_mapping` — a per-filament array
# `nozzle_mapping` — per-filament array of physical nozzle # of physical nozzle position IDs (`list[int]`). Forward it
# position IDs (`list[int]`). # verbatim onto the queue item so the dispatcher can replay it
# `nozzles_info` — per-extruder rack metadata # in its own project_file command. Without this the H2C
# (`list[dict]`, fields: id / type / flowSize / diameter). # firmware falls back to "last matching nozzle" auto-pick and
# Forward both verbatim onto the queue item so the dispatcher # ignores the user's Bambu Studio choice. Every other model
# can replay them in its own project_file command. Without # has it absent from slicer_opts, so the capture is a
# this the H2C firmware falls back to "last matching nozzle" # transparent no-op there. (`nozzles_info` was also captured
# auto-pick and ignores the user's Bambu Studio choice. Every # in the original fix but BambuStudio never actually sends it
# other model has these absent from slicer_opts, so the # — verified via wire capture on H2C — so only `nozzle_mapping`
# capture is a transparent no-op there. # 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("nozzle_mapping")
raw = slicer_opts.get(src_key) if raw is not None:
if raw is None: # BambuStudio's NetworkAgent embeds this as parsed
continue # JSON in the project_file body (matching the
# BambuStudio's NetworkAgent should embed these as # ams_mapping shape Bambuddy already consumes as
# parsed JSON in the project_file body (matching the # list[int]). Accept a JSON-encoded string defensively
# ams_mapping / ams_mapping2 shape Bambuddy already # in case any path arrives stringified.
# consumes as list[int] / list[dict]). 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 raw = None
encoded = json.dumps(raw) if raw is not None:
if src_key == "nozzle_mapping": nozzle_mapping_json = json.dumps(raw)
nozzle_mapping_json = encoded
else:
nozzles_info_json = encoded
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
@@ -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
View File
@@ -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 physical nozzle position ID array (`nozzle_mapping`). Without forwarding
rack metadata array (`nozzles_info`). Without forwarding both, the H2C it, the H2C firmware falls back to "last matching nozzle type" auto-pick
firmware falls back to "last matching nozzle type" auto-pick and ignores and ignores the user's slicer choice. Tests pin the gate, the parse, the
the user's slicer choice. Tests pin the gate, the parse, the no-op cases, no-op cases, and the malformed-JSON safety net.
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): def test_dual_nozzle_includes_nozzle_mapping(self, mqtt_client):
"""Dual-nozzle + both fields present → parsed JSON arrays injected """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]), nozzle_mapping=json.dumps([16, -1, -1, 1, -1, -1, -1, -1]),
nozzles_info=json.dumps(nozzles_info),
) )
cmd = self._published_print_cmd(mqtt_client) cmd = self._published_print_cmd(mqtt_client)
# Lists, not strings — the wire shape must match BambuStudio's. # List, not string — the wire shape must match BambuStudio's.
assert cmd["nozzle_mapping"] == [16, 0, 19] assert cmd["nozzle_mapping"] == [16, -1, -1, 1, -1, -1, -1, -1]
assert cmd["nozzles_info"] == nozzles_info
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 """A single-nozzle printer must NOT emit the rack field even if the
caller passes them (defense-in-depth — the queue item could legitimately 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 carries no nozzle_mapping. The firmware then runs its normal
normal auto-pick, which is the pre-fix behaviour for any non-O1C2 dual- 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:
@@ -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
@@ -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-filament physical nozzle position IDs in `nozzle_mapping`. VP
per-extruder rack metadata in `nozzles_info`. VP intake must store intake must store it as a JSON string on the queue item so the
both as JSON strings on the queue item so the dispatcher can replay dispatcher can replay it. Without this the H2C firmware falls back
them. Without this the H2C firmware falls back to "last matching to "last matching nozzle" auto-pick and ignores the user's slicer
nozzle" auto-pick and ignores the user's slicer choice. 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- # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of
# filament physical nozzle position IDs, nozzles_info = array of # per-filament physical nozzle position IDs (verified via H2C wire
# per-extruder rack-side metadata. # 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], "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1],
"nozzles_info": [
{"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
{"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
],
}, },
) )
@@ -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 _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1]
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"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_to_print_queue_no_nozzle_fields_when_slicer_omits(self, tmp_path): 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 / """#1780: every model other than O1C2 sends no nozzle_mapping — the
nozzles_info — the queue item must carry NULL on both, not an empty queue item must carry NULL, not an empty list. NULL is what the
list. NULL is what the dispatch layer keys off of to skip the dispatch layer keys off of to skip the injection entirely on non-
injection entirely on non-rack-swap printers. 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 same nozzle_mapping on every plate's queue item, not only the first.
only the first. Mirrors the per-plate stamping for gcode_injection, 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()