mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
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 ind196cfc5— 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:
+1
-1
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user