test_forget_printer.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. """What Bambuddy keeps per printer id goes when the printer is deleted.
  2. SQLite used to give a deleted printer's id to the next printer created, which
  3. inherited it all: the old print's owner (its prints passed the kill switch and
  4. were billed to that user), the plate-clear gate, pending notifications, the old
  5. camera feed, the old printer's plate-detection references. Ids are no longer
  6. reused, but nothing kept for a printer that is gone should stay.
  7. """
  8. import ast
  9. import asyncio
  10. from pathlib import Path
  11. import pytest
  12. from backend.app import main
  13. pytestmark = pytest.mark.asyncio
  14. GONE, KEPT = 5, 6
  15. # Module-level maps in main.py that are not keyed by a printer, or are kept on
  16. # purpose. Anything else main.py adds must be forgotten with the printer.
  17. NOT_FORGOTTEN = {
  18. "_print_ams_mappings": "keyed by (printer, archive); checked below",
  19. "_print_cost_center_ids": "keyed by (printer, archive); checked below",
  20. "_print_plate_ids": "keyed by (printer, archive); checked below",
  21. "_owned_print_archive_ids": "keyed by archive (dropped with the printer's expected/active prints)",
  22. "_archive_runs_finishing": "keyed by archive, cleared by the task it counts when that task ends",
  23. "_ams_assignment_locks": "a lock carries nothing over; popping one a task holds would let a second be made",
  24. "_fallback_recovery_locks": "a lock carries nothing over (see its comment on popping)",
  25. "_HMS_FAILURE_REASONS": "constant",
  26. "_ACTIVE_PRINT_STATES": "constant",
  27. "_SENSOR_TARGET_KEYS": "constant",
  28. "PUBLIC_API_ROUTES": "constant",
  29. "_HTML_CACHE_HEADERS": "constant",
  30. }
  31. def _module_maps() -> set[str]:
  32. """Names of main.py's module-level dicts and sets."""
  33. tree = ast.parse(Path(main.__file__).read_text())
  34. names = set()
  35. for node in tree.body:
  36. if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
  37. kind = ast.unparse(node.annotation)
  38. if kind.startswith(("dict", "set")):
  39. names.add(node.target.id)
  40. elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
  41. if isinstance(node.value, (ast.Dict, ast.Set)):
  42. names.add(node.targets[0].id)
  43. return names
  44. def _sleeper() -> asyncio.Task:
  45. return asyncio.ensure_future(asyncio.sleep(3600))
  46. def _entries(printer_id: int) -> dict[str, tuple]:
  47. """For each forgotten map: (key, value) for this printer."""
  48. return {
  49. "_active_prints": ((printer_id, "cube.3mf"), 100 + printer_id),
  50. "_expected_prints": ((printer_id, "cube.3mf"), 200 + printer_id),
  51. "_expected_print_creators": ((printer_id, "cube.3mf"), 1),
  52. "_expected_print_registered_at": ((printer_id, "cube.3mf"), 1.0),
  53. "_stage22_finish_frames": (printer_id, b"jpg"),
  54. "_stage22_finish_in_flight": (printer_id, asyncio.Event()),
  55. "_inprint_frame_bank": (printer_id, b"jpg"),
  56. "_inprint_frame_bank_ts": (printer_id, 1.0),
  57. "_printer_reconciled_since_connect": (printer_id, True),
  58. "_printer_kprofiles_primed_since_connect": (printer_id, True),
  59. "_last_progress_milestone": (printer_id, 50),
  60. "_first_layer_notified": (printer_id, True),
  61. "_unauthorized_print_kill_sent": (printer_id, None),
  62. "_kill_switch_notification_tasks": (printer_id, _sleeper()),
  63. "_notified_hms_errors": (printer_id, {"0300_0001": 1.0}),
  64. "_hms_print_state": (printer_id, "RUNNING"),
  65. "_hms_forget_when_gone": (printer_id, {"0300_0001"}),
  66. "_timelapse_baselines": (printer_id, {"video.mp4"}),
  67. "_timelapse_baselines_unread": (printer_id, None),
  68. "_bed_cool_waiters": (printer_id, {"threshold": 35.0, "filename": "cube", "registered_at": 1.0}),
  69. "_user_stopped_printers": (printer_id, None),
  70. "_kill_switch_stopped": (printer_id, None),
  71. "_printer_last_connected": (printer_id, True),
  72. "_printer_offline_published": (printer_id, None),
  73. "_printer_offline_notify_tasks": (printer_id, _sleeper()),
  74. "_unknown_tag_last_broadcast": (printer_id, {(0, 0): ("uid", "x")}),
  75. "_last_status_broadcast": (printer_id, "hash"),
  76. "_nozzle_count_updated": (printer_id, None),
  77. "_runtime_seen_connected": (printer_id, None),
  78. "_fallback_3mf_retry_tasks": (printer_id, _sleeper()),
  79. "_connection_watchdog_last_attempt": (printer_id, 1.0),
  80. "_ams_index_only_logged": ((printer_id, 0), None),
  81. "_ams_alarm_cooldown": (f"{printer_id}:0:humidity", None),
  82. }
  83. def _put(name: str, key, value) -> None:
  84. target = getattr(main, name)
  85. if isinstance(target, set):
  86. target.add(key)
  87. else:
  88. target[key] = value
  89. def _has(name: str, key) -> bool:
  90. return key in getattr(main, name)
  91. async def test_every_printer_keyed_map_in_main_is_forgotten():
  92. covered = set(_entries(GONE))
  93. unclassified = _module_maps() - covered - set(NOT_FORGOTTEN)
  94. assert not unclassified, (
  95. f"main.py maps neither forgotten with a deleted printer nor listed in NOT_FORGOTTEN: {sorted(unclassified)}"
  96. )
  97. async def test_forget_printer_clears_main_and_keeps_other_printers():
  98. gone, kept = _entries(GONE), _entries(KEPT)
  99. for name in gone:
  100. _put(name, *gone[name])
  101. _put(name, *kept[name])
  102. main._print_ams_mappings[(GONE, 100)] = [0]
  103. main._print_cost_center_ids[(GONE, 200)] = 1
  104. main._print_plate_ids[(GONE, 200)] = 2
  105. # Another printer's print of the same archive keeps what it registered.
  106. main._print_plate_ids[(KEPT, 200)] = 3
  107. pending = {name: gone[name][1] for name in ("_printer_offline_notify_tasks", "_fallback_3mf_retry_tasks")}
  108. try:
  109. await main.forget_printer(GONE)
  110. left = [name for name in gone if _has(name, gone[name][0])]
  111. assert left == []
  112. lost = [name for name in kept if not _has(name, kept[name][0])]
  113. assert lost == []
  114. assert (GONE, 100) not in main._print_ams_mappings
  115. assert (GONE, 200) not in main._print_cost_center_ids
  116. assert main._print_plate_ids == {(KEPT, 200): 3}
  117. await asyncio.sleep(0)
  118. assert all(task.cancelled() for task in pending.values())
  119. finally:
  120. for entries in (gone, kept):
  121. for name, (key, value) in entries.items():
  122. target = getattr(main, name)
  123. if isinstance(target, set):
  124. target.discard(key)
  125. else:
  126. target.pop(key, None)
  127. if isinstance(value, asyncio.Task):
  128. value.cancel()
  129. async def test_forget_printer_clears_the_services():
  130. from backend.app.api.routes import camera
  131. from backend.app.services import (
  132. bambu_ftp,
  133. kprofile_drift,
  134. layer_timelapse,
  135. obico_detection,
  136. print_dispatch_context,
  137. usage_tracker,
  138. )
  139. from backend.app.services.printer_manager import printer_manager
  140. printer_manager._current_print_user[GONE] = {"user_id": 1, "username": "alice"}
  141. printer_manager._awaiting_plate_clear.add(GONE)
  142. printer_manager._last_trays[GONE] = {"ams": []}
  143. print_dispatch_context.mark_pending(GONE)
  144. kprofile_drift.note_slot_configured(GONE, 0, 0, -1)
  145. kprofile_drift._watches[(GONE, 0, 1)] = kprofile_drift._SlotWatch()
  146. layer_timelapse._active_sessions[GONE] = type("S", (), {"cleanup": lambda self: None})()
  147. usage_tracker._active_sessions[GONE] = object()
  148. service = obico_detection.obico_detection_service
  149. for attr in ("_states", "_state_keys", "_last_class", "_errors", "_action_fired"):
  150. getattr(service, attr)[GONE] = object()
  151. bambu_ftp._threemf_path_cache[(GONE, "cube")] = Path("/nonexistent/cube.3mf")
  152. camera._last_frames[GONE] = b"jpg"
  153. camera._last_frame_times[GONE] = 1.0
  154. camera._stream_start_times[GONE] = 1.0
  155. camera._active_external_streams[GONE] = 1
  156. camera._external_live_readers[GONE] = 1
  157. from backend.app.api.routes import printers
  158. printers._cover_cache[GONE] = {("Cube", "front"): b"png"}
  159. printers._cover_404_cache[GONE] = {}
  160. from backend.app.services.printer_file_cache import printer_file_cache
  161. printer_file_cache._keep((GONE, "/cache/a.3mf"), b"3mf")
  162. await main.forget_printer(GONE)
  163. assert GONE not in printer_manager._current_print_user
  164. assert GONE not in printer_manager._awaiting_plate_clear
  165. assert GONE not in printer_manager._last_trays
  166. assert not print_dispatch_context.adopt(GONE)
  167. assert (GONE, 0, 0) not in kprofile_drift._default_chosen
  168. assert (GONE, 0, 1) not in kprofile_drift._watches
  169. assert GONE not in layer_timelapse._active_sessions
  170. assert GONE not in usage_tracker._active_sessions
  171. for attr in ("_states", "_state_keys", "_last_class", "_errors", "_action_fired"):
  172. assert GONE not in getattr(service, attr), attr
  173. assert (GONE, "cube") not in bambu_ftp._threemf_path_cache
  174. assert GONE not in camera._last_frames
  175. assert GONE not in camera._last_frame_times
  176. assert GONE not in camera._stream_start_times
  177. assert GONE not in camera._active_external_streams
  178. assert GONE not in camera._external_live_readers
  179. assert GONE not in printers._cover_cache
  180. assert GONE not in printers._cover_404_cache
  181. assert not [key for key in printer_file_cache._entries if key[0] == GONE]
  182. async def test_the_plate_references_of_a_printer_go(tmp_path, monkeypatch):
  183. from backend.app.services import plate_detection
  184. monkeypatch.setattr(plate_detection, "_get_calibration_dir", lambda: tmp_path)
  185. for printer_id in (GONE, KEPT, 55):
  186. (tmp_path / f"printer_{printer_id}_ref_0.jpg").write_bytes(b"jpg")
  187. (tmp_path / f"printer_{printer_id}_metadata.json").write_text("{}")
  188. plate_detection.remove_printer_references(GONE)
  189. assert sorted(p.name for p in tmp_path.iterdir()) == [
  190. "printer_55_metadata.json",
  191. "printer_55_ref_0.jpg",
  192. f"printer_{KEPT}_metadata.json",
  193. f"printer_{KEPT}_ref_0.jpg",
  194. ]
  195. async def test_the_scheduler_and_live_activities_forget_it():
  196. from backend.app.services.bambu_ftp import FtpFailureKind
  197. from backend.app.services.notify_live_activities import notify_live_activities
  198. from backend.app.services.print_scheduler import scheduler
  199. scheduler._upload_backoff[GONE] = 1e12
  200. scheduler._upload_refusals[GONE] = 6
  201. scheduler._upload_refusal_notified[GONE] = "printer is not accepting files"
  202. scheduler._upload_refusal_kind[GONE] = FtpFailureKind.STORAGE
  203. scheduler._wake_failures[GONE] = 1e12
  204. scheduler._dispatch_holds[GONE] = (1.0, "IDLE", None)
  205. scheduler._auto_dry_units[(GONE, 0)] = {}
  206. for attr in ("_snapshots", "_new_fallbacks", "_started_at", "_progress_resets"):
  207. getattr(notify_live_activities, attr)[GONE] = object()
  208. notify_live_activities._wake.clear()
  209. await main.forget_printer(GONE)
  210. assert GONE not in scheduler._upload_backoff
  211. assert GONE not in scheduler._upload_refusals
  212. assert GONE not in scheduler._upload_refusal_notified
  213. assert GONE not in scheduler._upload_refusal_kind
  214. assert GONE not in scheduler._wake_failures
  215. assert GONE not in scheduler._dispatch_holds
  216. assert (GONE, 0) not in scheduler._auto_dry_units
  217. for attr in ("_snapshots", "_new_fallbacks", "_started_at", "_progress_resets"):
  218. assert GONE not in getattr(notify_live_activities, attr), attr
  219. # Its tiles are ended at once (the printer is gone), before a new one takes the id.
  220. assert notify_live_activities._wake.is_set()
  221. @pytest.mark.parametrize("ml_fails", [False, True])
  222. async def test_an_obico_check_in_flight_when_the_printer_goes_ends_quietly(ml_fails):
  223. """The check waits up to 30 s for the ML server. A KeyError there stopped the
  224. poll for every printer; an error written back stuck to the next printer given the id."""
  225. from unittest.mock import AsyncMock, MagicMock, patch
  226. from httpx import Request, Response
  227. from backend.app.services.obico_detection import ObicoDetectionService
  228. service = ObicoDetectionService()
  229. async def ml_call(url, **_kwargs):
  230. service.forget_printer(GONE) # deleted meanwhile
  231. if ml_fails:
  232. raise OSError("ML server gone")
  233. return Response(200, json={"detections": []}, request=Request("GET", url))
  234. client = MagicMock()
  235. client.get = AsyncMock(side_effect=ml_call)
  236. client.__aenter__ = AsyncMock(return_value=client)
  237. client.__aexit__ = AsyncMock(return_value=False)
  238. settings = {"sensitivity": "medium", "snapshot_base_url": "http://b", "ml_url": "http://o", "action": "notify"}
  239. with (
  240. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=client),
  241. patch.object(service, "_capture_frame", new=AsyncMock(return_value=b"\xff\xd8jpg\xff\xd9")),
  242. ):
  243. await service._check_printer(GONE, MagicMock(state="RUNNING", task_name="job", subtask_name=""), settings)
  244. for attr in ("_states", "_state_keys", "_last_class", "_errors", "_action_fired"):
  245. assert GONE not in getattr(service, attr), attr
  246. assert service._last_error is None