| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- """A plate printed entirely from the external spool must dispatch use_ams=False (#3087).
- The reporter's P1S sat at "Heatbed preheating" for ten and a half minutes and
- then paused with 07FF_8012, "Failed to get AMS mapping table". The plate was one
- filament, mapped by hand to the external spool, out of a seven-filament
- MakerWorld project -- so the mapping was ``[-1, -1, -1, -1, -1, -1, 254]`` and
- the command went out as ``use_ams: true`` with a flat mapping of nothing but
- -1 (254 is deliberately not sent raw: the firmware reads it as AMS tray 0).
- The decision belongs here rather than in the MQTT command builder. Down there a
- -1 is either padding for a filament this plate does not print -- BambuStudio's
- own convention, and what the other six entries are -- or a slot that never
- resolved, which must never be redirected to the spool holder (#2589). The two
- are the same byte. Only the plate's own filament list tells them apart, and
- ``extract_filament_requirements`` already drops anything with ``used_g <= 0``,
- so it names exactly the slots that are printed.
- """
- from __future__ import annotations
- import json
- import zipfile
- from contextlib import ExitStack
- from pathlib import Path
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- import backend.app.services.print_scheduler as scheduler_module
- from backend.app.core.database import Base
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- from backend.app.models.settings import Settings # noqa: F401 - registers the table
- from backend.app.services.print_scheduler import PrintScheduler
- from backend.tests._fixtures.background_tasks import discarding_spawn_patch
- pytestmark = pytest.mark.integration
- # The reporter's plate: filament 7 of a seven-filament project, and it is the
- # only one this plate consumes. slice_info.config lists a plate's filaments by
- # their project-wide id, which is why the mapping is seven long.
- _PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
- def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr(
- "Metadata/slice_info.config",
- f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
- )
- def _write_3mf_without_slice_info(path: Path) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr("3D/3dmodel.model", "<model/>")
- @pytest.fixture
- async def dispatch_case(tmp_path):
- engine = create_async_engine("sqlite+aiosqlite:///:memory:")
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- session_maker = async_sessionmaker(engine, expire_on_commit=False)
- base_dir = tmp_path / "external-spool"
- async def _build(
- mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S"
- ):
- archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
- if slice_info:
- _write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments)
- else:
- _write_3mf_without_slice_info(base_dir / archive_rel)
- async with session_maker() as db:
- printer = Printer(
- name="P1S",
- serial_number=f"01P{abs(hash(str(mapping))) % 10**9}",
- ip_address="127.0.0.1",
- access_code="access-code",
- model=model,
- )
- db.add(printer)
- await db.flush()
- archive = PrintArchive(
- printer_id=printer.id,
- filename=archive_rel.name,
- file_path=str(archive_rel),
- file_size=(base_dir / archive_rel).stat().st_size,
- status="completed",
- )
- db.add(archive)
- await db.flush()
- item = PrintQueueItem(
- printer_id=printer.id,
- archive_id=archive.id,
- plate_id=plate_id,
- status="pending",
- use_ams=use_ams,
- ams_mapping=json.dumps(mapping) if mapping is not None else None,
- )
- db.add(item)
- await db.commit()
- return SimpleNamespace(item_id=item.id, printer_id=printer.id)
- try:
- yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
- finally:
- await engine.dispose()
- async def _dispatch(ctx, ids, status=None):
- scheduler = PrintScheduler()
- start_print = MagicMock(return_value=True)
- status = status or SimpleNamespace(state="IDLE", nozzle_rack=None, raw_data={}, nozzles=[])
- with ExitStack() as stack:
- for patcher in (
- patch.object(scheduler_module, "async_session", ctx.session_maker),
- patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
- 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.print_scheduler.printer_manager.start_print", start_print),
- patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
- patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
- patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
- patch(
- "backend.app.services.print_scheduler.get_ftp_retry_settings",
- AsyncMock(return_value=(False, 3, 2.0, 30.0)),
- ),
- patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
- discarding_spawn_patch(),
- patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
- patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
- patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
- patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
- patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
- patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
- ):
- stack.enter_context(patcher)
- await scheduler._dispatch_one(ids.item_id)
- assert start_print.call_count == 1, "the print command was never sent"
- return start_print.call_args
- class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
- async def test_the_reporters_mapping_dispatches_without_the_ams(self, dispatch_case):
- """[-1]*6 + [254] on a plate whose only printed filament is #7."""
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254])
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is False
- # The mapping itself still goes out untouched — the builder is what
- # turns 254 into -1 plus ams_mapping2, and none of that changes.
- assert call.kwargs["ams_mapping"] == [-1, -1, -1, -1, -1, -1, 254]
- async def test_the_main_nozzle_sentinel_counts_too(self, dispatch_case):
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 255])
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is False
- async def test_an_unpadded_single_filament_plate_is_unaffected(self, dispatch_case):
- """[254] already worked: the MQTT command builder downgrades an
- all-external mapping by itself. The scheduler now reaches the same
- answer one layer earlier, so the two agree rather than one undoing the
- other — this pins that they do."""
- ids = await dispatch_case.build([254], filaments='<filament id="1" used_g="9.0" type="PLA"/>', plate_id=1)
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is False
- class TestWhatMustNotChange:
- async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
- """The #2589 contract, and the reason this lives in the scheduler.
- Filaments 1 and 7 are both printed; 7 is on the spool holder and 1
- resolved to nothing. Redirecting the plate to the external spool would
- print filament 1 in the wrong material without saying so. use_ams stays
- true and the firmware rejects the print, exactly as before.
- """
- ids = await dispatch_case.build(
- [-1, -1, -1, -1, -1, -1, 254],
- filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
- )
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_a_plate_mixing_an_ams_tray_with_the_spool_holder_keeps_the_ams(self, dispatch_case):
- ids = await dispatch_case.build(
- [5, -1, -1, -1, -1, -1, 254],
- filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
- )
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_a_plate_printed_from_ams_trays_is_untouched(self, dispatch_case):
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5])
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_use_ams_false_is_never_promoted_here(self, dispatch_case):
- """Promotion is the builder's job (#2595) and stays there."""
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5], use_ams=False)
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is False
- async def test_a_3mf_with_no_filament_list_falls_back_to_the_stored_flag(self, dispatch_case):
- """No evidence, no decision — the same convention as #2771."""
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], slice_info=False)
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_a_plate_the_file_does_not_describe_falls_back(self, dispatch_case):
- """The item says plate 4; the file only describes plate 1."""
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], plate_id=4)
- # Rewrite the archive's 3MF so its only plate is index 1.
- async with dispatch_case.session_maker() as db:
- archive = (await db.get(PrintQueueItem, ids.item_id)).archive_id
- path = dispatch_case.base_dir / (await db.get(PrintArchive, archive)).file_path
- _write_3mf(path, plate_index=1)
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_an_item_with_no_mapping_at_all_is_untouched(self, dispatch_case):
- ids = await dispatch_case.build(None)
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- class TestDualNozzleIsNotOursToRewrite:
- """On a two-extruder printer use_ams is which nozzle to feed, not whether to
- use the AMS — H2D Pro firmware reads it as an extruder index. The MQTT
- command builder skips its own reconcile for exactly that reason, and this
- must skip it too, or a perfectly normal dual external-spool print gets its
- routing rewritten."""
- async def test_a_dual_nozzle_model_keeps_its_flag(self, dispatch_case):
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2D")
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_both_external_feeds_on_a_dual_nozzle_are_left_alone(self, dispatch_case):
- """254 is the deputy feed and 255 the main one — an ordinary H2D print
- with a spool on each side, and the one this would have broken."""
- ids = await dispatch_case.build(
- [254, -1, -1, -1, -1, -1, 255],
- filaments='<filament id="1" used_g="8.0" type="PLA"/>' + _PLATE_4_ONE_FILAMENT,
- model="H2D",
- )
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is True
- async def test_live_telemetry_can_veto_a_single_nozzle_model_name(self, dispatch_case):
- """A model string we do not recognise as dual is not the last word: two
- external feeds is something only a two-extruder printer reports."""
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
- status = SimpleNamespace(
- state="IDLE",
- nozzle_rack=None,
- nozzles=[],
- raw_data={"vt_tray": [{"id": "254"}, {"id": "255"}]},
- )
- call = await _dispatch(dispatch_case, ids, status=status)
- assert call.kwargs["use_ams"] is True
- async def test_a_second_nozzle_reporting_a_diameter_vetoes_it_too(self, dispatch_case):
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
- status = SimpleNamespace(
- state="IDLE",
- nozzle_rack=None,
- nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")],
- raw_data={},
- )
- call = await _dispatch(dispatch_case, ids, status=status)
- assert call.kwargs["use_ams"] is True
- async def test_h2s_is_single_nozzle_and_still_gets_the_fix(self, dispatch_case):
- """H2S shares the H2 serial prefix and firmware quirks but has one
- extruder — the #1386 distinction, which must survive here."""
- ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2S")
- call = await _dispatch(dispatch_case, ids)
- assert call.kwargs["use_ams"] is False
|