Преглед на файлове

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 дни
родител
ревизия
24b8485f87
променени са 3 файла, в които са добавени 182 реда и са изтрити 4 реда
  1. 1 0
      CHANGELOG.md
  2. 40 4
      backend/app/services/print_scheduler.py
  3. 141 0
      backend/tests/unit/test_external_mapping_on_ams_printer_3239.py

+ 1 - 0
CHANGELOG.md

@@ -122,6 +122,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### 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.
 - **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.

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

@@ -3664,10 +3664,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,
@@ -3718,6 +3719,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:
@@ -3731,6 +3743,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]