test_external_spool_use_ams_3087.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. """A plate printed entirely from the external spool must dispatch use_ams=False (#3087).
  2. The reporter's P1S sat at "Heatbed preheating" for ten and a half minutes and
  3. then paused with 07FF_8012, "Failed to get AMS mapping table". The plate was one
  4. filament, mapped by hand to the external spool, out of a seven-filament
  5. MakerWorld project -- so the mapping was ``[-1, -1, -1, -1, -1, -1, 254]`` and
  6. the command went out as ``use_ams: true`` with a flat mapping of nothing but
  7. -1 (254 is deliberately not sent raw: the firmware reads it as AMS tray 0).
  8. The decision belongs here rather than in the MQTT command builder. Down there a
  9. -1 is either padding for a filament this plate does not print -- BambuStudio's
  10. own convention, and what the other six entries are -- or a slot that never
  11. resolved, which must never be redirected to the spool holder (#2589). The two
  12. are the same byte. Only the plate's own filament list tells them apart, and
  13. ``extract_filament_requirements`` already drops anything with ``used_g <= 0``,
  14. so it names exactly the slots that are printed.
  15. """
  16. from __future__ import annotations
  17. import json
  18. import zipfile
  19. from contextlib import ExitStack
  20. from pathlib import Path
  21. from types import SimpleNamespace
  22. from unittest.mock import AsyncMock, MagicMock, patch
  23. import pytest
  24. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  25. import backend.app.models # noqa: F401 - populate Base.metadata
  26. import backend.app.services.print_scheduler as scheduler_module
  27. from backend.app.core.database import Base
  28. from backend.app.models.archive import PrintArchive
  29. from backend.app.models.print_queue import PrintQueueItem
  30. from backend.app.models.printer import Printer
  31. from backend.app.models.settings import Settings # noqa: F401 - registers the table
  32. from backend.app.services.print_scheduler import PrintScheduler
  33. from backend.tests._fixtures.background_tasks import discarding_spawn_patch
  34. pytestmark = pytest.mark.integration
  35. # The reporter's plate: filament 7 of a seven-filament project, and it is the
  36. # only one this plate consumes. slice_info.config lists a plate's filaments by
  37. # their project-wide id, which is why the mapping is seven long.
  38. _PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
  39. def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None:
  40. path.parent.mkdir(parents=True, exist_ok=True)
  41. with zipfile.ZipFile(path, "w") as zf:
  42. zf.writestr(
  43. "Metadata/slice_info.config",
  44. f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
  45. )
  46. def _write_3mf_without_slice_info(path: Path) -> None:
  47. path.parent.mkdir(parents=True, exist_ok=True)
  48. with zipfile.ZipFile(path, "w") as zf:
  49. zf.writestr("3D/3dmodel.model", "<model/>")
  50. @pytest.fixture
  51. async def dispatch_case(tmp_path):
  52. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  53. async with engine.begin() as conn:
  54. await conn.run_sync(Base.metadata.create_all)
  55. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  56. base_dir = tmp_path / "external-spool"
  57. async def _build(
  58. mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S"
  59. ):
  60. archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
  61. if slice_info:
  62. _write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments)
  63. else:
  64. _write_3mf_without_slice_info(base_dir / archive_rel)
  65. async with session_maker() as db:
  66. printer = Printer(
  67. name="P1S",
  68. serial_number=f"01P{abs(hash(str(mapping))) % 10**9}",
  69. ip_address="127.0.0.1",
  70. access_code="access-code",
  71. model=model,
  72. )
  73. db.add(printer)
  74. await db.flush()
  75. archive = PrintArchive(
  76. printer_id=printer.id,
  77. filename=archive_rel.name,
  78. file_path=str(archive_rel),
  79. file_size=(base_dir / archive_rel).stat().st_size,
  80. status="completed",
  81. )
  82. db.add(archive)
  83. await db.flush()
  84. item = PrintQueueItem(
  85. printer_id=printer.id,
  86. archive_id=archive.id,
  87. plate_id=plate_id,
  88. status="pending",
  89. use_ams=use_ams,
  90. ams_mapping=json.dumps(mapping) if mapping is not None else None,
  91. )
  92. db.add(item)
  93. await db.commit()
  94. return SimpleNamespace(item_id=item.id, printer_id=printer.id)
  95. try:
  96. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
  97. finally:
  98. await engine.dispose()
  99. async def _dispatch(ctx, ids, status=None):
  100. scheduler = PrintScheduler()
  101. start_print = MagicMock(return_value=True)
  102. status = status or SimpleNamespace(state="IDLE", nozzle_rack=None, raw_data={}, nozzles=[])
  103. with ExitStack() as stack:
  104. for patcher in (
  105. patch.object(scheduler_module, "async_session", ctx.session_maker),
  106. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  107. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  108. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
  109. patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
  110. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  111. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  112. patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
  113. patch(
  114. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  115. AsyncMock(return_value=(False, 3, 2.0, 30.0)),
  116. ),
  117. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  118. discarding_spawn_patch(),
  119. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  120. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  121. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  122. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  123. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  124. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  125. ):
  126. stack.enter_context(patcher)
  127. await scheduler._dispatch_one(ids.item_id)
  128. assert start_print.call_count == 1, "the print command was never sent"
  129. return start_print.call_args
  130. class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
  131. async def test_the_reporters_mapping_dispatches_without_the_ams(self, dispatch_case):
  132. """[-1]*6 + [254] on a plate whose only printed filament is #7."""
  133. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254])
  134. call = await _dispatch(dispatch_case, ids)
  135. assert call.kwargs["use_ams"] is False
  136. # The mapping itself still goes out untouched — the builder is what
  137. # turns 254 into -1 plus ams_mapping2, and none of that changes.
  138. assert call.kwargs["ams_mapping"] == [-1, -1, -1, -1, -1, -1, 254]
  139. async def test_the_main_nozzle_sentinel_counts_too(self, dispatch_case):
  140. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 255])
  141. call = await _dispatch(dispatch_case, ids)
  142. assert call.kwargs["use_ams"] is False
  143. async def test_an_unpadded_single_filament_plate_is_unaffected(self, dispatch_case):
  144. """[254] already worked: the MQTT command builder downgrades an
  145. all-external mapping by itself. The scheduler now reaches the same
  146. answer one layer earlier, so the two agree rather than one undoing the
  147. other — this pins that they do."""
  148. ids = await dispatch_case.build([254], filaments='<filament id="1" used_g="9.0" type="PLA"/>', plate_id=1)
  149. call = await _dispatch(dispatch_case, ids)
  150. assert call.kwargs["use_ams"] is False
  151. class TestWhatMustNotChange:
  152. async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
  153. """The #2589 contract, and the reason this lives in the scheduler.
  154. Filaments 1 and 7 are both printed; 7 is on the spool holder and 1
  155. resolved to nothing. Redirecting the plate to the external spool would
  156. print filament 1 in the wrong material without saying so. use_ams stays
  157. true and the firmware rejects the print, exactly as before.
  158. """
  159. ids = await dispatch_case.build(
  160. [-1, -1, -1, -1, -1, -1, 254],
  161. filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
  162. )
  163. call = await _dispatch(dispatch_case, ids)
  164. assert call.kwargs["use_ams"] is True
  165. async def test_a_plate_mixing_an_ams_tray_with_the_spool_holder_keeps_the_ams(self, dispatch_case):
  166. ids = await dispatch_case.build(
  167. [5, -1, -1, -1, -1, -1, 254],
  168. filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
  169. )
  170. call = await _dispatch(dispatch_case, ids)
  171. assert call.kwargs["use_ams"] is True
  172. async def test_a_plate_printed_from_ams_trays_is_untouched(self, dispatch_case):
  173. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5])
  174. call = await _dispatch(dispatch_case, ids)
  175. assert call.kwargs["use_ams"] is True
  176. async def test_use_ams_false_is_never_promoted_here(self, dispatch_case):
  177. """Promotion is the builder's job (#2595) and stays there."""
  178. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5], use_ams=False)
  179. call = await _dispatch(dispatch_case, ids)
  180. assert call.kwargs["use_ams"] is False
  181. async def test_a_3mf_with_no_filament_list_falls_back_to_the_stored_flag(self, dispatch_case):
  182. """No evidence, no decision — the same convention as #2771."""
  183. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], slice_info=False)
  184. call = await _dispatch(dispatch_case, ids)
  185. assert call.kwargs["use_ams"] is True
  186. async def test_a_plate_the_file_does_not_describe_falls_back(self, dispatch_case):
  187. """The item says plate 4; the file only describes plate 1."""
  188. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], plate_id=4)
  189. # Rewrite the archive's 3MF so its only plate is index 1.
  190. async with dispatch_case.session_maker() as db:
  191. archive = (await db.get(PrintQueueItem, ids.item_id)).archive_id
  192. path = dispatch_case.base_dir / (await db.get(PrintArchive, archive)).file_path
  193. _write_3mf(path, plate_index=1)
  194. call = await _dispatch(dispatch_case, ids)
  195. assert call.kwargs["use_ams"] is True
  196. async def test_an_item_with_no_mapping_at_all_is_untouched(self, dispatch_case):
  197. ids = await dispatch_case.build(None)
  198. call = await _dispatch(dispatch_case, ids)
  199. assert call.kwargs["use_ams"] is True
  200. class TestDualNozzleIsNotOursToRewrite:
  201. """On a two-extruder printer use_ams is which nozzle to feed, not whether to
  202. use the AMS — H2D Pro firmware reads it as an extruder index. The MQTT
  203. command builder skips its own reconcile for exactly that reason, and this
  204. must skip it too, or a perfectly normal dual external-spool print gets its
  205. routing rewritten."""
  206. async def test_a_dual_nozzle_model_keeps_its_flag(self, dispatch_case):
  207. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2D")
  208. call = await _dispatch(dispatch_case, ids)
  209. assert call.kwargs["use_ams"] is True
  210. async def test_both_external_feeds_on_a_dual_nozzle_are_left_alone(self, dispatch_case):
  211. """254 is the deputy feed and 255 the main one — an ordinary H2D print
  212. with a spool on each side, and the one this would have broken."""
  213. ids = await dispatch_case.build(
  214. [254, -1, -1, -1, -1, -1, 255],
  215. filaments='<filament id="1" used_g="8.0" type="PLA"/>' + _PLATE_4_ONE_FILAMENT,
  216. model="H2D",
  217. )
  218. call = await _dispatch(dispatch_case, ids)
  219. assert call.kwargs["use_ams"] is True
  220. async def test_live_telemetry_can_veto_a_single_nozzle_model_name(self, dispatch_case):
  221. """A model string we do not recognise as dual is not the last word: two
  222. external feeds is something only a two-extruder printer reports."""
  223. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
  224. status = SimpleNamespace(
  225. state="IDLE",
  226. nozzle_rack=None,
  227. nozzles=[],
  228. raw_data={"vt_tray": [{"id": "254"}, {"id": "255"}]},
  229. )
  230. call = await _dispatch(dispatch_case, ids, status=status)
  231. assert call.kwargs["use_ams"] is True
  232. async def test_a_second_nozzle_reporting_a_diameter_vetoes_it_too(self, dispatch_case):
  233. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
  234. status = SimpleNamespace(
  235. state="IDLE",
  236. nozzle_rack=None,
  237. nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")],
  238. raw_data={},
  239. )
  240. call = await _dispatch(dispatch_case, ids, status=status)
  241. assert call.kwargs["use_ams"] is True
  242. async def test_h2s_is_single_nozzle_and_still_gets_the_fix(self, dispatch_case):
  243. """H2S shares the H2 serial prefix and firmware quirks but has one
  244. extruder — the #1386 distinction, which must survive here."""
  245. ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2S")
  246. call = await _dispatch(dispatch_case, ids)
  247. assert call.kwargs["use_ams"] is False