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.
This commit is contained in:
maziggy
2026-10-05 09:43:55 +02:00
parent 6c8a821bca
commit 24b8485f87
3 changed files with 182 additions and 4 deletions
+1
View File
@@ -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
View File
@@ -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"))
@@ -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]