فهرست منبع

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 2 هفته پیش
والد
کامیت
10cd7cc9f2

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

@@ -1994,6 +1994,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

@@ -2259,9 +2259,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:
@@ -3496,6 +3510,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 "
@@ -3509,6 +3536,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