pause_eta.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. """Time to the next pause the slicer put into a print, and a heads-up before it (#2984).
  2. The pauses come from the plate's G-code (threemf_tools.parse_gcode_pause_points)
  3. with the slicer's time left at each. The time to one is the printer's time left
  4. minus that: both count down to the end of the same print.
  5. The printer manager serializes ``next_pause`` into every status, so nothing here
  6. imports it at module level.
  7. """
  8. import logging
  9. import math
  10. import time
  11. from backend.app.core.tasks import spawn_background_task
  12. logger = logging.getLogger(__name__)
  13. DEFAULT_WARNING_MINUTES = 5
  14. # The largest heads-up the setting allows. A pause further away than this is
  15. # not looked at again until it comes closer, so the setting is read at most
  16. # once per check interval, and only while a pause is this close.
  17. MAX_WARNING_MINUTES = 120
  18. # Time left is reported in whole minutes; checking more often finds nothing new.
  19. _CHECK_INTERVAL_SECONDS = 30.0
  20. _last_check: dict[int, float] = {}
  21. def next_pause(state) -> dict | None:
  22. """The next pause of the running print: ``{"layer", "minutes"}``, or None.
  23. The next is the first on a layer above the one printing: the printer is
  24. on a pause's layer while it waits there and after it resumes, so that
  25. pause is behind it. ``minutes`` is None while the printer reports no time
  26. left (the start of a print); the layer is known all the same. Only while
  27. RUNNING: a paused print is waiting at its pause, or was paused by hand.
  28. """
  29. if getattr(state, "state", None) != "RUNNING":
  30. return None
  31. layer_now = getattr(state, "layer_num", 0) or 0
  32. remaining_now = getattr(state, "remaining_time", 0) or 0
  33. # The slicer's times are for standard speed. In another mode the printer
  34. # rescales its own time left, by a factor not measured yet, so a time to
  35. # the pause worked out from both would be wrong; the layer is still right.
  36. standard_speed = getattr(state, "speed_level", 2) == 2
  37. for point in getattr(state, "pause_points", None) or ():
  38. if point.layer > layer_now:
  39. minutes = None
  40. if standard_speed and remaining_now > 0 and point.remaining_minutes is not None:
  41. minutes = max(0, remaining_now - point.remaining_minutes)
  42. return {"layer": point.layer, "minutes": minutes}
  43. return None
  44. def observe(printer_id: int, state) -> None:
  45. """Check on a status push whether a pause is close enough to warn about.
  46. Cheap on every push: the setting is read in a task, at most once per
  47. check interval, and only while an unwarned pause is within the largest
  48. heads-up the setting allows.
  49. """
  50. upcoming = next_pause(state)
  51. if (
  52. upcoming is None
  53. or upcoming["minutes"] is None
  54. or upcoming["minutes"] > MAX_WARNING_MINUTES
  55. or upcoming["layer"] in state.pause_warned_layers
  56. ):
  57. return
  58. now = time.monotonic()
  59. if now - _last_check.get(printer_id, -math.inf) < _CHECK_INTERVAL_SECONDS:
  60. return
  61. _last_check[printer_id] = now
  62. spawn_background_task(_warn_if_due(printer_id, upcoming["layer"]), name=f"pause-warning-{printer_id}")
  63. async def warning_minutes(db) -> int:
  64. """The heads-up setting, in minutes; the default for a missing or bad value."""
  65. from backend.app.api.routes.settings import get_setting
  66. value = await get_setting(db, "pause_warning_minutes")
  67. try:
  68. minutes = int(value) if value else DEFAULT_WARNING_MINUTES
  69. except (TypeError, ValueError):
  70. return DEFAULT_WARNING_MINUTES
  71. return min(max(minutes, 1), MAX_WARNING_MINUTES)
  72. # Kept on the print's archive under this key, so a restart does not warn again.
  73. _WARNED_KEY = "pause_warned_layers"
  74. def warned_layers_of(archive, state) -> set[int]:
  75. """The pauses this print was warned about before a restart.
  76. Only for the same run: a reprint reuses its archive row, and its
  77. subtask_id is the one thing that tells the runs apart.
  78. """
  79. saved = (getattr(archive, "extra_data", None) or {}).get(_WARNED_KEY) or {}
  80. subtask_id = str(getattr(state, "subtask_id", "") or "")
  81. if not isinstance(saved, dict) or subtask_id in ("", "0") or saved.get("subtask_id") != subtask_id:
  82. return set()
  83. return {layer for layer in saved.get("layers") or [] if isinstance(layer, int)}
  84. async def _remember_warned(db, printer_id: int, state) -> None:
  85. from backend.app.main import running_print_archive
  86. archive = await running_print_archive(db, printer_id, state, printing_only=True, by_name=False)
  87. if archive is None:
  88. return
  89. subtask_id = str(getattr(state, "subtask_id", "") or "")
  90. archive.extra_data = {
  91. **(archive.extra_data or {}),
  92. _WARNED_KEY: {"subtask_id": subtask_id, "layers": sorted(state.pause_warned_layers)},
  93. }
  94. await db.commit()
  95. async def _warn_if_due(printer_id: int, layer: int) -> None:
  96. from backend.app.core.database import async_session
  97. from backend.app.models.printer import Printer
  98. from backend.app.services.notification_service import notification_service
  99. from backend.app.services.printer_manager import printer_manager
  100. try:
  101. async with async_session() as db:
  102. lead = await warning_minutes(db)
  103. client = printer_manager.get_client(printer_id)
  104. if client is None:
  105. return
  106. state = client.state
  107. # Read again after the await: the print may have moved on.
  108. upcoming = next_pause(state)
  109. if (
  110. upcoming is None
  111. or upcoming["layer"] != layer
  112. or upcoming["minutes"] is None
  113. or upcoming["minutes"] > lead
  114. or layer in state.pause_warned_layers
  115. ):
  116. return
  117. state.pause_warned_layers.add(layer)
  118. printer = await db.get(Printer, printer_id)
  119. if printer is None:
  120. return
  121. await notification_service.on_print_pause_upcoming(
  122. printer_id,
  123. printer.name,
  124. state.subtask_name or state.gcode_file or "Unknown",
  125. minutes=upcoming["minutes"],
  126. layer=layer,
  127. total_layers=state.total_layers,
  128. db=db,
  129. )
  130. try:
  131. await _remember_warned(db, printer_id, state)
  132. except Exception:
  133. logger.warning(
  134. "[Printer %s] Could not keep the pause heads-up on the archive", printer_id, exc_info=True
  135. )
  136. except Exception:
  137. logger.warning("[Printer %s] Pause heads-up failed", printer_id, exc_info=True)