Ver código fonte

Remap a job sent from an external spool to a printer with an AMS (#3239)

A queue item keeps the AMS mapping it was made with. A job made for a
printer without an AMS ([254], external spool) that was moved to a
location could be picked by a printer with an AMS, and still went out
with use_ams false; the printer stopped with "External filament is
missing". The #2799 check skipped it because an external tray the
printer doesn't report as loaded counted as no evidence.

A printer that reports its external spool empty, while its AMS holds
the filament type the slot needs in a similar colour, now counts as a
conflict, so the mapping is recomputed for that printer. AMS-less
printers, including ones whose external spool has no filament set, keep
the stored mapping, and with only another colour in the AMS the printer
still asks for the spool.
maziggy 2 semanas atrás
pai
commit
804d615892

+ 40 - 4
backend/app/services/print_scheduler.py

@@ -3625,10 +3625,11 @@ class PrintScheduler:
         the firmware's own type check, so a PETG slot happily prints in ASA.
 
         Returns a short reason when the mapping names a tray this printer does
-        not have loaded, or points a slot at a tray holding a different filament
-        type. Returns None when the mapping fits, and — deliberately — whenever
-        we lack the evidence to judge, so a recompute only ever follows a
-        positive finding.
+        not have loaded, points a slot at a tray holding a different filament
+        type, or points it at an external spool this printer reports empty while
+        its AMS holds that slot's filament (#3239). Returns None when the
+        mapping fits, and — deliberately — whenever we lack the evidence to
+        judge, so a recompute only ever follows a positive finding.
 
         An unresolved (``-1``) required slot is NOT a conflict: it says the
         matcher had nothing, not that the mapping belongs to another printer,
@@ -3679,6 +3680,17 @@ class PrintScheduler:
             return None
         self._apply_filament_overrides(item, required)
 
+        # External feeds the printer reports and reports as empty. That is
+        # evidence, unlike an external feed it says nothing about (#3239).
+        empty_external: set[int] = set()
+        vt_trays = status.raw_data.get("vt_tray") if isinstance(status.raw_data, dict) else None
+        for vt in vt_trays if isinstance(vt_trays, list) else []:
+            if isinstance(vt, dict) and not vt.get("tray_type"):
+                try:
+                    empty_external.add(int(vt.get("id", 254)))
+                except (TypeError, ValueError):
+                    continue
+
         for req in required:
             slot_id = req.get("slot_id") or 0
             if slot_id <= 0:
@@ -3692,6 +3704,30 @@ class PrintScheduler:
 
             loaded_tray = by_tray.get(tray)
             if loaded_tray is None:
+                # A mapping made for a printer that feeds this slot from its
+                # external spool, sent to one whose external spool is empty and
+                # whose AMS holds the filament (#3239). Only then: an external
+                # spool can be loaded without its filament set, and on a printer
+                # with nothing else to offer that job printed before. The colour
+                # has to match too: with only another colour in the AMS, the
+                # printer asking for the spool beats printing in that colour.
+                want = canonical_filament_type(req.get("type"))
+                if tray >= 254 and tray in empty_external and want:
+                    ams_tray = next(
+                        (
+                            f
+                            for f in loaded
+                            if not f.get("is_external")
+                            and canonical_filament_type(f.get("type")) == want
+                            and self._colors_are_similar(f.get("color"), req.get("color"))
+                        ),
+                        None,
+                    )
+                    if ams_tray is not None:
+                        return (
+                            f"slot {slot_id} points at the external spool, which is empty, "
+                            f"while tray {ams_tray['global_tray_id']} holds {ams_tray.get('type')}"
+                        )
                 continue
 
             want = canonical_filament_type(req.get("type"))

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

@@ -0,0 +1,141 @@
+"""A mapping made for an external spool, sent to a printer whose AMS holds it (#3239).
+
+Two A1 in one location, both with black PETG: A1-018 has no AMS and feeds it
+from the external spool (``[254]``), A1-002 holds it in AMS tray 0 and has an
+empty external spool. A failed job of A1-018 was queued again and moved to the
+location, kept ``[254]``, and was picked by A1-002, which then stopped at once
+with "External filament is missing". The #2799 check let it through because an
+external tray the printer doesn't report as loaded counted as no evidence.
+
+The printers' trays below are the reporter's, from the support bundle.
+"""
+
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+BLACK_PETG = [{"slot_id": 1, "type": "PETG", "color": "#000000", "tray_info_idx": "GFG99"}]
+
+EMPTY_EXTERNAL = {"id": "254", "tray_type": "", "tray_color": "00000000", "remain": 0, "tray_info_idx": ""}
+LOADED_EXTERNAL = {"id": "254", "tray_type": "PETG", "tray_color": "000000FF", "remain": 80, "tray_info_idx": "GFG99"}
+
+A1_002_AMS = [
+    {
+        "id": "0",
+        "tray": [
+            {"id": "0", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG99"},
+            {"id": "1", "tray_type": "PETG", "tray_color": "C52C18FF", "tray_info_idx": "GFG99"},
+            {"id": "2", "tray_type": "PETG", "tray_color": "AC95D5FF", "tray_info_idx": "GFG99"},
+            {"id": "3", "tray_type": "PETG", "tray_color": "0085D5FF", "tray_info_idx": "GFG99"},
+        ],
+    }
+]
+
+
+def _status(ams, vt_tray):
+    return SimpleNamespace(
+        raw_data={"ams": ams, "vt_tray": [vt_tray], "ams_extruder_map": {}},
+        nozzles=[],
+        fila_switch=None,
+    )
+
+
+def _item():
+    item = MagicMock()
+    item.id = 2453
+    item.printer_id = None
+    item.ams_mapping = json.dumps([254])
+    item.skip_filament_check = False
+    item.filament_overrides = None
+    item.required_filament_types = None
+    item.manual_start = False
+    item.filament_short = False
+    return item
+
+
+def _scheduler():
+    scheduler = PrintScheduler()
+    scheduler._get_filament_requirements = AsyncMock(return_value=[dict(r) for r in BLACK_PETG])
+    scheduler._get_bool_setting = AsyncMock(return_value=False)
+    return scheduler
+
+
+async def _ensure(status):
+    scheduler = _scheduler()
+    item = _item()
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = status
+        await scheduler._ensure_ams_mapping(AsyncMock(), 12, item)
+    return json.loads(item.ams_mapping) if item.ams_mapping else None
+
+
+@pytest.mark.asyncio
+async def test_the_reporters_job_is_remapped_to_the_ams_tray():
+    assert await _ensure(_status(A1_002_AMS, EMPTY_EXTERNAL)) == [0]
+
+
+@pytest.mark.asyncio
+async def test_the_printer_it_was_made_for_keeps_the_external_spool():
+    """A1-018: no AMS, black PETG on the external spool."""
+    assert await _ensure(_status([], LOADED_EXTERNAL)) == [254]
+
+
+@pytest.mark.asyncio
+async def test_an_external_spool_without_its_filament_set_keeps_printing():
+    """No AMS, external spool loaded but never given a filament type: there is
+    nothing else to feed from, so the job goes out as it always did."""
+    assert await _ensure(_status([], EMPTY_EXTERNAL)) == [254]
+
+
+@pytest.mark.asyncio
+async def test_an_ams_without_the_filament_does_not_take_the_slot():
+    """An empty external spool alone proves nothing when the AMS can't print the
+    slot either; the mapping is left for the existing checks."""
+    other_material = [
+        {"id": "0", "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFL99"}]}
+    ]
+    scheduler = _scheduler()
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = _status(other_material, EMPTY_EXTERNAL)
+        conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 12, _item(), [254])
+    assert conflict is None
+
+
+@pytest.mark.asyncio
+async def test_another_colour_in_the_ams_does_not_take_the_slot():
+    """A job for this very printer whose external spool is just empty for now:
+    the printer asking for the spool beats printing it in white."""
+    white_only = [
+        {"id": "0", "tray": [{"id": "0", "tray_type": "PETG", "tray_color": "FFFFFFFF", "tray_info_idx": "GFG99"}]}
+    ]
+    scheduler = _scheduler()
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = _status(white_only, EMPTY_EXTERNAL)
+        conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 12, _item(), [254])
+    assert conflict is None
+
+
+@pytest.mark.asyncio
+async def test_the_conflict_names_the_empty_external_spool():
+    scheduler = _scheduler()
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = _status(A1_002_AMS, EMPTY_EXTERNAL)
+        conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 12, _item(), [254])
+    assert conflict is not None
+    assert "external spool" in conflict
+    assert "tray 0" in conflict
+
+
+@pytest.mark.asyncio
+async def test_print_anyway_still_keeps_the_users_mapping():
+    scheduler = _scheduler()
+    item = _item()
+    item.skip_filament_check = True
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        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]