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