test_current_archive_lan_print_3327.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """A print started outside the cloud is linked to its archive (#3327).
  2. Bambu Studio over LAN, Handy and the printer's screen report subtask_id "0",
  3. which Bambuddy stores as no id at all, so status found the archive of such a
  4. print by subtask_id never: current_archive_id stayed null for the whole print,
  5. and the printer card could not name the plate of a multi-plate file. Status now
  6. falls back to the archive Bambuddy bound to the running print, and after a
  7. restart, which forgets that binding, to its printing archive by name. The
  8. cover reads the running print's 3MF from that archive too instead of the
  9. printer. Skip-objects does only through the binding: by name it could offer an
  10. earlier run's objects to skip.
  11. """
  12. import logging
  13. import zipfile
  14. from datetime import datetime
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.core.config import settings as app_settings
  18. from backend.app.main import _active_prints, _restore_printable_objects
  19. from backend.app.services.bambu_mqtt import PrinterState
  20. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  21. NAME = "Benchy plates"
  22. @pytest.fixture(autouse=True)
  23. def _bindings():
  24. _active_prints.clear()
  25. yield
  26. _active_prints.clear()
  27. def _state(run_state="RUNNING", subtask_id="0"):
  28. state = PrinterState()
  29. state.connected = True
  30. state.state = run_state
  31. state.subtask_id = subtask_id
  32. state.subtask_name = NAME
  33. state.gcode_file = "/data/Metadata/plate_5.gcode"
  34. return state
  35. async def _statuses(async_client, states):
  36. with patch("backend.app.api.routes.printers.printer_manager") as manager:
  37. manager.get_status = MagicMock(side_effect=lambda pid: states.get(pid))
  38. manager.is_awaiting_plate_clear = MagicMock(return_value=False)
  39. singles = {}
  40. for pid in states:
  41. response = await async_client.get(f"/api/v1/printers/{pid}/status")
  42. assert response.status_code == 200
  43. singles[pid] = response.json()["current_archive_id"]
  44. bulk = await async_client.get("/api/v1/bulk/printer-statuses", params={"ids": ",".join(map(str, states))})
  45. assert bulk.status_code == 200
  46. assert {row["id"]: row["current_archive_id"] for row in bulk.json()} == singles
  47. return singles
  48. async def test_a_lan_print_is_linked_to_its_archive(async_client, printer_factory, archive_factory):
  49. printer = await printer_factory(model="H2C")
  50. archive = await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
  51. _active_prints[(printer.id, f"{NAME}.3mf")] = archive.id
  52. linked = await _statuses(async_client, {printer.id: _state()})
  53. assert linked[printer.id] == archive.id
  54. async def test_also_while_paused(async_client, printer_factory, archive_factory):
  55. printer = await printer_factory()
  56. archive = await archive_factory(printer.id, status="printing", subtask_id=None)
  57. _active_prints[(printer.id, NAME)] = archive.id
  58. linked = await _statuses(async_client, {printer.id: _state("PAUSE")})
  59. assert linked[printer.id] == archive.id
  60. async def test_not_before_its_archive_exists(async_client, printer_factory, archive_factory):
  61. """Nor to an archive of an earlier print that also had no id."""
  62. printer = await printer_factory()
  63. await archive_factory(printer.id, status="printing", subtask_id=None)
  64. await archive_factory(printer.id, status="printing", subtask_id="0")
  65. linked = await _statuses(async_client, {printer.id: _state()})
  66. assert linked[printer.id] is None
  67. async def test_not_to_another_printers_print(async_client, printer_factory, archive_factory):
  68. printer, other = await printer_factory(), await printer_factory()
  69. archive = await archive_factory(other.id, status="printing", subtask_id=None)
  70. _active_prints[(other.id, NAME)] = archive.id
  71. linked = await _statuses(async_client, {printer.id: _state()})
  72. assert linked[printer.id] is None
  73. async def test_not_once_the_print_is_over(async_client, printer_factory, archive_factory):
  74. printer = await printer_factory()
  75. archive = await archive_factory(printer.id, status="printing", subtask_id=None)
  76. _active_prints[(printer.id, NAME)] = archive.id
  77. linked = await _statuses(async_client, {printer.id: _state("FINISH")})
  78. assert linked[printer.id] is None
  79. async def test_a_cloud_print_is_still_linked_by_its_id(async_client, printer_factory, archive_factory):
  80. printer = await printer_factory()
  81. by_id = await archive_factory(printer.id, status="printing", subtask_id="524288")
  82. bound = await archive_factory(printer.id, status="printing", subtask_id=None)
  83. _active_prints[(printer.id, NAME)] = bound.id
  84. linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
  85. assert linked[printer.id] == by_id.id
  86. async def test_a_cloud_print_without_an_archive_by_its_id_uses_the_binding(
  87. async_client, printer_factory, archive_factory
  88. ):
  89. printer = await printer_factory()
  90. bound = await archive_factory(printer.id, status="printing", subtask_id=None)
  91. _active_prints[(printer.id, NAME)] = bound.id
  92. linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
  93. assert linked[printer.id] == bound.id
  94. class TestAfterARestart:
  95. """The binding is gone and only Bambuddy's own prints are bound again."""
  96. async def test_a_lan_print_is_found_by_its_name(self, async_client, printer_factory, archive_factory):
  97. printer = await printer_factory(model="H2C")
  98. await archive_factory(printer.id, status="printing", subtask_id=None, filename="Older.3mf", print_name="Older")
  99. archive = await archive_factory(
  100. printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", print_name=NAME
  101. )
  102. linked = await _statuses(async_client, {printer.id: _state()})
  103. assert linked[printer.id] == archive.id
  104. async def test_as_the_printer_echoes_the_name(self, async_client, printer_factory, archive_factory):
  105. printer = await printer_factory()
  106. archive = await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
  107. state = _state()
  108. state.subtask_name = NAME.replace(" ", "_")
  109. linked = await _statuses(async_client, {printer.id: state})
  110. assert linked[printer.id] == archive.id
  111. async def test_not_a_finished_run_of_the_same_file(self, async_client, printer_factory, archive_factory):
  112. printer = await printer_factory()
  113. await archive_factory(printer.id, status="completed", subtask_id=None, filename=f"{NAME}.3mf")
  114. linked = await _statuses(async_client, {printer.id: _state()})
  115. assert linked[printer.id] is None
  116. async def test_not_a_print_that_had_an_id_of_its_own(self, async_client, printer_factory, archive_factory):
  117. printer = await printer_factory()
  118. await archive_factory(printer.id, status="printing", subtask_id="524288", filename=f"{NAME}.3mf")
  119. linked = await _statuses(async_client, {printer.id: _state()})
  120. assert linked[printer.id] is None
  121. async def test_not_for_a_cloud_print(self, async_client, printer_factory, archive_factory):
  122. """Its own id finds its archive; a name is no better than that."""
  123. printer = await printer_factory()
  124. await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
  125. linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
  126. assert linked[printer.id] is None
  127. async def test_not_a_deleted_archive(self, async_client, printer_factory, archive_factory):
  128. printer = await printer_factory()
  129. await archive_factory(
  130. printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", deleted_at=datetime.now()
  131. )
  132. linked = await _statuses(async_client, {printer.id: _state()})
  133. assert linked[printer.id] is None
  134. class TestADeletedArchive:
  135. """Deleting the archive of a running print leaves its binding behind."""
  136. async def test_is_not_named_through_the_binding(self, async_client, printer_factory, archive_factory, db_session):
  137. printer = await printer_factory()
  138. archive = await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
  139. _active_prints[(printer.id, NAME)] = archive.id
  140. await db_session.delete(archive)
  141. await db_session.commit()
  142. linked = await _statuses(async_client, {printer.id: _state()})
  143. assert linked[printer.id] is None
  144. async def test_nor_when_moved_to_the_bin(self, async_client, printer_factory, archive_factory):
  145. printer = await printer_factory()
  146. archive = await archive_factory(printer.id, status="printing", subtask_id=None, deleted_at=datetime.now())
  147. _active_prints[(printer.id, NAME)] = archive.id
  148. linked = await _statuses(async_client, {printer.id: _state()})
  149. assert linked[printer.id] is None
  150. async def test_nor_by_a_cloud_prints_id(self, async_client, printer_factory, archive_factory):
  151. printer = await printer_factory()
  152. await archive_factory(printer.id, status="printing", subtask_id="524288", deleted_at=datetime.now())
  153. linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
  154. assert linked[printer.id] is None
  155. def _job_3mf(tmp_path):
  156. """A real zip without a cover, so the cover route answers 404 from it."""
  157. path = tmp_path / "job.3mf"
  158. with zipfile.ZipFile(path, "w") as zf:
  159. zf.writestr("Metadata/slice_info.config", "<config/>")
  160. return str(path.relative_to(tmp_path))
  161. class TestTheRunningPrintsFileOnDisk:
  162. """What else finds the running print's archive finds a "0" print's too."""
  163. async def test_the_cover_comes_from_the_archive(self, async_client, printer_factory, archive_factory, tmp_path):
  164. printer = await printer_factory(model="H2C")
  165. await archive_factory(
  166. printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", file_path=_job_3mf(tmp_path)
  167. )
  168. state = _state()
  169. state.sdcard = True
  170. ftp = AsyncMock(return_value=None)
  171. with (
  172. patch.object(app_settings, "base_dir", tmp_path),
  173. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  174. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  175. patch("backend.app.api.routes.printers.download_file_try_paths_async", new=ftp),
  176. ):
  177. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  178. assert response.status_code == 404, response.text
  179. ftp.assert_not_awaited()
  180. async def test_the_skip_objects_reload_reads_the_archive(self, async_client, printer_factory, archive_factory):
  181. printer = await printer_factory(model="H2C")
  182. archive = await archive_factory(printer.id, status="printing", subtask_id=None)
  183. _active_prints[(printer.id, NAME)] = archive.id
  184. client = MagicMock(state=_state())
  185. ftp = AsyncMock(return_value=None)
  186. with (
  187. patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
  188. patch(
  189. "backend.app.services.archive.extract_printable_objects_from_archive",
  190. return_value=({101: {"name": "left"}}, None),
  191. ) as from_disk,
  192. patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
  193. ):
  194. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  195. assert response.status_code == 200, response.text
  196. assert response.json()["total"] == 1
  197. from_disk.assert_called_once()
  198. ftp.assert_not_awaited()
  199. async def test_the_object_list_comes_from_the_bound_archive(self, printer_factory, archive_factory, db_session):
  200. """A reconnect without a restart: the binding is still there."""
  201. printer = await printer_factory(model="H2C")
  202. archive = await archive_factory(printer.id, status="printing", subtask_id=None)
  203. _active_prints[(printer.id, NAME)] = archive.id
  204. with (
  205. patch("backend.app.main.printer_manager") as pm,
  206. patch("backend.app.main._load_objects_from_archive") as load,
  207. ):
  208. pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
  209. await _restore_printable_objects(printer.id, _state(), db_session, logging.getLogger(__name__))
  210. load.assert_called_once()
  211. assert load.call_args.args[0].id == archive.id
  212. async def test_but_never_by_name_after_a_restart(self, printer_factory, archive_factory, db_session):
  213. """An earlier run of the same name, left printing by a completion
  214. missed while Bambuddy was down, cannot be told from this one."""
  215. printer = await printer_factory(model="H2C")
  216. await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
  217. with (
  218. patch("backend.app.main.printer_manager") as pm,
  219. patch("backend.app.main._load_objects_from_archive") as load,
  220. ):
  221. pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
  222. await _restore_printable_objects(printer.id, _state(), db_session, logging.getLogger(__name__))
  223. load.assert_not_called()
  224. async def test_nor_does_the_reload(self, async_client, printer_factory, archive_factory):
  225. """It asks the printer for the file it is running instead."""
  226. printer = await printer_factory(model="H2C")
  227. await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
  228. client = MagicMock(state=_state())
  229. ftp = AsyncMock(return_value=None)
  230. with (
  231. patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
  232. patch(
  233. "backend.app.services.archive.extract_printable_objects_from_archive",
  234. return_value=({101: {"name": "left"}}, None),
  235. ) as from_disk,
  236. patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
  237. ):
  238. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  239. assert response.status_code == 200, response.text
  240. from_disk.assert_not_called()
  241. ftp.assert_awaited()
  242. async def test_not_from_an_archive_without_its_3mf(self, printer_factory, archive_factory, db_session):
  243. printer = await printer_factory(model="H2C")
  244. archive = await archive_factory(
  245. printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", file_path=""
  246. )
  247. _active_prints[(printer.id, NAME)] = archive.id
  248. with (
  249. patch("backend.app.main.printer_manager") as pm,
  250. patch("backend.app.main._load_objects_from_archive") as load,
  251. ):
  252. pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
  253. await _restore_printable_objects(printer.id, _state(), db_session, logging.getLogger(__name__))
  254. load.assert_not_called()
  255. async def test_nor_from_a_finished_archive_with_the_same_job_id(self, printer_factory, archive_factory, db_session):
  256. """A leftover row of a completion never seen must not lend its objects."""
  257. printer = await printer_factory(model="H2C")
  258. await archive_factory(printer.id, status="completed", subtask_id="524288", filename=f"{NAME}.3mf")
  259. with (
  260. patch("backend.app.main.printer_manager") as pm,
  261. patch("backend.app.main._load_objects_from_archive") as load,
  262. ):
  263. pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
  264. await _restore_printable_objects(
  265. printer.id, _state(subtask_id="524288"), db_session, logging.getLogger(__name__)
  266. )
  267. load.assert_not_called()