Browse Source

Match a location job's trays on the printer it gets (#3239)

A job kept the AMS mapping of the printer it was made for. Moved to a
location and picked by another printer, a tray of the same material in
another colour passed the fit check, so the job printed in that colour.
The scheduler now drops a stored mapping when it assigns a job by model
or location and matches the trays on the chosen printer, by type and
colour. Moving a job from a printer to a model or location clears its
mapping and the "Print Anyway" given for that printer.

A printer without an AMS whose external spool has no filament set gave
the matcher nothing to match, so such a job went out without a mapping
and the firmware rejected it with 0700_8012. Every filament now goes to
the external spool there.
maziggy 1 day ago
parent
commit
57dd8651d8

+ 1 - 0
CHANGELOG.md

@@ -127,6 +127,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Fixed
 - **A job moved to a location or model could print from an empty external spool on a printer that has the filament in its AMS (#3239, reported by @hyyz17200)** — A job keeps the AMS mapping of the printer it was made for. When a job made for a printer without an AMS, which feeds from the external spool, was moved to a location and picked by a printer with an AMS, it still told that printer to use its external spool, and the print stopped at once with "External filament is missing". The check added for #2799 didn't catch it, because it read an external spool the printer reports as empty as "no information". Now, when the printer reports its external spool empty and its AMS holds the filament the job needs, in the same colour, the mapping is worked out again for that printer. A printer without an AMS keeps using its external spool as before, also when the spool's filament was never set, and with only another colour in the AMS the printer still asks for the spool rather than print in that colour.
+- **A job moved to a location or model could print in the wrong colour (#3239, reported by @hyyz17200)** — The fix above only covered the external spool. A job still kept the AMS trays of the printer it was made for, so a job for black PETG in tray 3 of one printer, moved to a location and picked by a printer with blue PETG in tray 3, printed in blue: the tray existed and held the right material, so nothing stopped it. A job that Bambuddy places on a printer by model or location now gets its trays matched on that printer, by material and colour, and is held for a spool when nothing fits. Moving a job from a printer to a model or location also clears its tray choice and any "Print Anyway" given for that printer. A job sent to one printer keeps the trays picked for it. A printer without an AMS whose external spool has no filament set now prints every filament from that spool; such a job used to go out without a mapping and stop with 0700_8012 "Failed to get AMS mapping table".
 - **The SD-card cleanup could delete a file the printer was printing (#3009, reported by @bdwilson)** — If a print finished while Bambuddy was stopped and you then reprinted the same file from the printer's screen, Bambuddy closed the old print when it came back and deleted its file from the SD card, even though the printer was printing that file right then. The cleanup now leaves a file alone while the printer is printing it.
 - **Permissions taken away from a group came back after a restart (#3238, reported by @Minebuddy)** — Turning off **View MakerWorld** and **Import MakerWorld** for a group and restarting Bambuddy turned them back on. Several permissions added in past releases were given to every matching group on each start, not just once on the upgrade that introduced them: MakerWorld, **Clear plate**, stock forecasting and slicer pipelines. Each is now given once, and a permission you remove stays removed. Updating doesn't re-enable anything you have already turned off. The built-in Administrators, Operators and Viewers groups, whose permissions can't be edited, are still kept complete on each start.
 - **A virtual printer with Save AMS mapping lost the slicer's external-spool pick (#3237, reported by @erabti)** — The slicer marks a filament fed from the external spool as -1 in its slot list, the same as a filament with no slot, and says which spool it is in a second list. Bambuddy saved only the first list, so the queued print sent that filament as unassigned. On an H2C the printer then stopped before the first layer with 0700-8012, "Failed to get AMS mapping table". The saved mapping now keeps the external spool, left or right on dual-nozzle printers, for the queued print and for reprints from the archive.

+ 8 - 0
backend/app/api/routes/print_queue.py

@@ -2081,6 +2081,14 @@ async def update_queue_item(
                 f"File was sliced for {sliced_for} and cannot be dispatched to {update_data['target_model']} printers",
             )
 
+    # A job that now waits for any printer of a model keeps nothing that was
+    # resolved against one printer: the scheduler picks the printer and maps
+    # its trays then (#3239). The edit dialog omits both fields on such a move.
+    if new_target_model and not new_printer_id:
+        update_data["ams_mapping"] = None
+        if item.printer_id and "skip_filament_check" not in update_data:
+            update_data["skip_filament_check"] = False
+
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None

+ 86 - 3
backend/app/services/print_scheduler.py

@@ -2292,9 +2292,23 @@ class PrintScheduler:
                             db=db,
                         )
 
-                        # Resolve the AMS mapping for the assigned printer when it's
-                        # missing OR unresolved (all -1). Critical for model-based
-                        # jobs where mapping wasn't computed upfront, and it also
+                        # A mapping on this item was not made for the printer just
+                        # picked: it came with a job moved here from a fixed
+                        # printer, or from a variant. Its tray IDs can name a
+                        # tray of the right type in another colour here, which
+                        # the fit check lets through (#3239). Match afresh, by
+                        # type and colour.
+                        if item.ams_mapping:
+                            logger.info(
+                                "Queue item %s: dropping stored ams_mapping %s, not made for printer %s",
+                                item.id,
+                                item.ams_mapping,
+                                printer_id,
+                            )
+                            item.ams_mapping = None
+
+                        # Resolve the AMS mapping for the assigned printer. It is
+                        # always missing here, so this computes it, and it also
                         # self-heals a bogus stored [-1] (#2589).
                         unmappable = await self._ensure_ams_mapping(db, printer_id, item)
                         if unmappable:
@@ -3535,6 +3549,19 @@ class PrintScheduler:
             await db.commit()
             return None
 
+        external_only = await self._external_spool_only_mapping(db, printer_id, item)
+        if external_only is not None:
+            item.ams_mapping = json.dumps(external_only)
+            logger.info(
+                "Queue item %s: printer %s has no AMS and its external spool has no filament set; "
+                "mapping every filament to it: %s",
+                item.id,
+                printer_id,
+                external_only,
+            )
+            await db.commit()
+            return None
+
         if _mapping_is_all_unresolved(stored_mapping):
             logger.warning(
                 "Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
@@ -3548,6 +3575,62 @@ class PrintScheduler:
 
         return await self._unmappable_without_ams_message(db, printer_id, item, computed_mapping)
 
+    async def _external_spool_only_mapping(
+        self, db: AsyncSession, printer_id: int, item: PrintQueueItem
+    ) -> list[int] | None:
+        """Every filament on the external spool, for a printer that has nothing else (#3239).
+
+        A printer without an AMS prints from its external spool, and an external
+        spool whose filament was never set reports no type, so the matcher has
+        nothing to match. Sent without a mapping, the print goes out with the AMS
+        on and the firmware rejects it with 0700_8012. A stored ``[254]`` used
+        to carry such a job through; a job placed by model or location no longer
+        keeps one, and one queued that way never had one.
+
+        Only on a positive report: the printer has said it has no AMS, it has a
+        single external feed (a dual-nozzle printer's feeds steer nozzles, which
+        is not ours to pick), and no feed has a filament set — with one set, the
+        matcher has already given its answer. Not for a job that asked for its
+        colours to be matched strictly: a spool without a filament set has no
+        colour to check. Returns None otherwise.
+        """
+        if item.filament_overrides:
+            try:
+                overrides = json.loads(item.filament_overrides)
+            except (json.JSONDecodeError, TypeError):
+                return None
+            if not isinstance(overrides, list) or any(
+                isinstance(o, dict) and o.get("force_color_match") for o in overrides
+            ):
+                return None
+        status = printer_manager.get_status(printer_id)
+        if status is None or not isinstance(status.raw_data, dict):
+            return None
+        ams_units = status.raw_data.get("ams")
+        if not isinstance(ams_units, list) or ams_units:
+            return None
+        vt_trays = status.raw_data.get("vt_tray")
+        if not isinstance(vt_trays, list) or len(vt_trays) != 1 or not isinstance(vt_trays[0], dict):
+            return None
+        if self._build_loaded_filaments(status):
+            return None
+        printer = await self._get_printer(db, printer_id)
+        if _might_be_dual_nozzle(printer.model if printer else None, status):
+            return None
+        external = _int_or(vt_trays[0].get("id"), _EXTERNAL_TRAY_ID_MIN)
+        if external < _EXTERNAL_TRAY_ID_MIN:
+            return None
+
+        required = await self._get_filament_requirements(db, item)
+        slot_ids = [r.get("slot_id") for r in required or []]
+        slot_ids = [s for s in slot_ids if isinstance(s, int) and s > 0]
+        if not slot_ids:
+            return None
+        mapping = [-1] * max(slot_ids)
+        for slot_id in slot_ids:
+            mapping[slot_id - 1] = external
+        return mapping
+
     async def _fill_unresolved_slots(
         self,
         db: AsyncSession,

+ 32 - 0
backend/tests/integration/test_print_queue_api.py

@@ -2113,6 +2113,38 @@ class TestTargetLocationFeature:
         result = response.json()
         assert result["target_location"] == "Basement"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_moving_a_job_to_a_location_drops_the_old_printers_mapping(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """#3239: the tray IDs were resolved against A1-003, and the scheduler may
+        pick another printer, so they must not travel with the job. Neither does
+        a "Print Anyway" given for A1-003's trays."""
+        a1_003 = await printer_factory(model="A1", location="Farm")
+        item = await queue_item_factory(printer_id=a1_003.id, ams_mapping="[3]", skip_filament_check=True)
+
+        # What the edit dialog sends: ams_mapping omitted, not null.
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"printer_id": None, "target_model": "A1", "target_location": "Farm"},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+        assert result["skip_filament_check"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_job_on_a_printer_keeps_its_mapping(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        item = await queue_item_factory(ams_mapping="[3]")
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 2})
+        assert response.status_code == 200
+        assert response.json()["ams_mapping"] == [3]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):

+ 188 - 0
backend/tests/unit/test_external_mapping_on_ams_printer_3239.py

@@ -139,3 +139,191 @@ async def test_print_anyway_still_keeps_the_users_mapping():
         pm.get_status.return_value = _status(A1_002_AMS, EMPTY_EXTERNAL)
         await scheduler._ensure_ams_mapping(AsyncMock(), 12, item)
     assert json.loads(item.ams_mapping) == [254]
+
+
+# A job made for A1-003, where black PETG sits in AMS tray 3, moved to the
+# location and picked by A1-002, whose tray 3 holds blue PETG. Tray 3 exists
+# and holds PETG, so the stored [3] fits by type and would print in blue.
+A1_002_BLUE_IN_TRAY_3 = [
+    {
+        "id": "0",
+        "tray": [
+            {"id": "0", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG99"},
+            {"id": "3", "tray_type": "PETG", "tray_color": "0085D5FF", "tray_info_idx": "GFG99"},
+        ],
+    }
+]
+
+
+@pytest.fixture
+async def session_maker():
+    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+    import backend.app.models  # noqa: F401 - populate Base.metadata
+    from backend.app.core.database import Base
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    try:
+        yield async_sessionmaker(engine, expire_on_commit=False)
+    finally:
+        await engine.dispose()
+
+
+async def _location_job(session_maker, ams_mapping):
+    from backend.app.models.library import LibraryFile
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.printer import Printer
+
+    async with session_maker() as db:
+        db.add(
+            Printer(
+                id=2,
+                name="A1-002",
+                serial_number="A1002",
+                ip_address="10.0.0.2",
+                access_code="x",
+                model="A1",
+                location="Farm",
+                is_active=True,
+            )
+        )
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": "A1"},
+        )
+        db.add(lib)
+        await db.flush()
+        item = PrintQueueItem(
+            status="pending",
+            position=1,
+            target_model="A1",
+            target_location="Farm",
+            library_file_id=lib.id,
+            ams_mapping=ams_mapping,
+        )
+        db.add(item)
+        await db.commit()
+        return item.id
+
+
+async def _mapping_after_a_pass(session_maker, item_id):
+    from backend.app.models.print_queue import PrintQueueItem
+
+    scheduler = _scheduler()
+    scheduler._get_job_name = AsyncMock(return_value="job")
+    status = _status(A1_002_BLUE_IN_TRAY_3, EMPTY_EXTERNAL)
+    with (
+        patch("backend.app.services.print_scheduler.async_session", session_maker),
+        patch("backend.app.core.database.async_session", session_maker),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_assigned", AsyncMock()),
+        patch.object(scheduler, "_find_idle_printer_for_model", AsyncMock(return_value=(2, None))),
+        patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+        patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_launch_uploads", MagicMock()),
+    ):
+        await scheduler.check_queue()
+    async with session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+        return item.printer_id, json.loads(item.ams_mapping) if item.ams_mapping else None
+
+
+@pytest.mark.asyncio
+async def test_a_job_moved_to_a_location_is_mapped_for_the_printer_it_gets(session_maker):
+    item_id = await _location_job(session_maker, json.dumps([3]))
+
+    assert await _mapping_after_a_pass(session_maker, item_id) == (2, [0])
+
+
+@pytest.mark.asyncio
+async def test_a_location_job_without_a_mapping_is_matched_the_same(session_maker):
+    item_id = await _location_job(session_maker, None)
+
+    assert await _mapping_after_a_pass(session_maker, item_id) == (2, [0])
+
+
+async def _ensure_unmapped(status, model="A1", required=None):
+    """A job with no stored mapping, as a location job now always arrives."""
+    scheduler = _scheduler()
+    if required is not None:
+        scheduler._get_filament_requirements = AsyncMock(return_value=required)
+    scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(model=model))
+    item = _item()
+    item.ams_mapping = None
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = status
+        message = await scheduler._ensure_ams_mapping(AsyncMock(), 12, item)
+    return (json.loads(item.ams_mapping) if item.ams_mapping else None), message
+
+
+@pytest.mark.asyncio
+async def test_a_printer_without_ams_prints_from_its_unset_external_spool():
+    """A1-018 picks the job from the location: no AMS, and its external spool
+    holds the black PETG without a filament set. Without a mapping the print
+    would go out with the AMS on and be rejected with 0700_8012."""
+    assert await _ensure_unmapped(_status([], EMPTY_EXTERNAL)) == ([254], None)
+
+
+@pytest.mark.asyncio
+async def test_every_printed_filament_goes_to_the_external_spool():
+    required = [
+        {"slot_id": 1, "type": "PETG", "color": "#000000"},
+        {"slot_id": 3, "type": "PETG", "color": "#FFFFFF"},
+    ]
+    assert await _ensure_unmapped(_status([], EMPTY_EXTERNAL), required=required) == ([254, -1, 254], None)
+
+
+@pytest.mark.asyncio
+async def test_a_set_external_spool_is_matched_as_before():
+    assert await _ensure_unmapped(_status([], LOADED_EXTERNAL)) == ([254], None)
+
+
+@pytest.mark.asyncio
+async def test_an_external_spool_set_to_another_material_still_fails_the_job():
+    """The matcher answered: the only spool holds PLA. That stays a failure
+    with a message (#2771), not a print in the wrong material."""
+    pla = {**LOADED_EXTERNAL, "tray_type": "PLA", "tray_info_idx": "GFL99"}
+    mapping, message = await _ensure_unmapped(_status([], pla))
+    assert mapping is None
+    assert message
+
+
+@pytest.mark.asyncio
+async def test_a_printer_with_an_ams_is_not_sent_to_the_external_spool():
+    empty_ams = [{"id": "0", "tray": [{"id": "0", "tray_type": ""}]}]
+    assert await _ensure_unmapped(_status(empty_ams, EMPTY_EXTERNAL)) == (None, None)
+
+
+@pytest.mark.asyncio
+async def test_a_printer_that_has_not_reported_its_ams_is_left_alone():
+    status = SimpleNamespace(raw_data={"vt_tray": [EMPTY_EXTERNAL]}, nozzles=[], fila_switch=None)
+    assert await _ensure_unmapped(status) == (None, None)
+
+
+@pytest.mark.asyncio
+async def test_a_dual_nozzle_printer_is_left_alone():
+    """Its two external feeds steer the nozzles; which one is not ours to pick."""
+    status = _status([], EMPTY_EXTERNAL)
+    status.raw_data["vt_tray"] = [EMPTY_EXTERNAL, {**EMPTY_EXTERNAL, "id": "255"}]
+    assert await _ensure_unmapped(status, model="H2D") == (None, None)
+
+
+@pytest.mark.asyncio
+async def test_a_job_that_wants_its_colours_matched_strictly_is_left_alone():
+    """Force colour match: a spool without a filament set has no colour to check."""
+    scheduler = _scheduler()
+    scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(model="A1"))
+    item = _item()
+    item.ams_mapping = None
+    item.filament_overrides = json.dumps(
+        [{"slot_id": 1, "type": "PETG", "color": "#000000", "force_color_match": True}]
+    )
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = _status([], EMPTY_EXTERNAL)
+        assert await scheduler._external_spool_only_mapping(AsyncMock(), 12, item) is None

+ 2 - 1
backend/tests/unit/test_scheduler_cross_model_variants.py

@@ -353,7 +353,8 @@ async def test_first_matching_variant_wins_and_is_folded_onto_the_row(queue_db):
     assert item.printer_id == 2, "assigned to the H2C"
     assert item.target_model == "H2C"
     assert item.plate_id == 3
-    assert item.ams_mapping == "[4, 5]"
+    # A variant's mapping was not made for H2C-1; it is matched on the printer (#3239).
+    assert item.ams_mapping is None
     assert item.nozzle_mapping == "[0, 1]"
     assert item.print_time_seconds == 1200, "the estimate now describes what will actually run"
     assert item.waiting_reason is None