slot_unlink_grace.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """Hold an automatic slot unlink until "the spool is gone" has lasted.
  2. Both inventory backends unlink a slot's spool when the AMS says the slot is
  3. empty: the built-in inventory deletes the ``spool_assignment`` row, Spoolman
  4. mode deletes the ``spoolman_slot_assignments`` row. Until #3186 one MQTT push
  5. was enough. An idle X1 Carbon on X1Plus firmware sent a push that cleared an
  6. entire AMS unit -- presence bits off, colour and type blank -- and four saved
  7. assignments were deleted in the same instant. The spools never moved. When
  8. the next push reported them again, nothing brought the rows back, and the
  9. identity of a non-RFID spool exists nowhere but in that row.
  10. A spool that really was taken out stays out, so the evidence is cheap to
  11. confirm: note when a slot first looks empty, keep the row, and unlink only if
  12. it still looks empty ``GRACE_SECONDS`` later. A slot that reads normally again
  13. in between is forgotten. Evidence of a *different* spool -- another colour or
  14. type, another Bambu tag -- is not held here; callers unlink that immediately.
  15. The unlink passes run only when the AMS hash changes, so a slot that goes
  16. empty and stays empty might never be looked at again. Holding a removal
  17. therefore also schedules one re-check per printer, which re-runs just the
  18. cleanup passes against the printer's current AMS state.
  19. """
  20. import asyncio
  21. import logging
  22. import time
  23. from collections.abc import Awaitable, Callable
  24. logger = logging.getLogger(__name__)
  25. GRACE_SECONDS = 120.0
  26. # A hold that nobody re-observed for this long is out of date -- the pass that
  27. # would have seen it did not run (Spoolman unreachable, printer offline) -- so
  28. # the next sighting starts the clock again instead of confirming at once.
  29. _STALE_AFTER = 2 * GRACE_SECONDS
  30. # (printer_id, scope, ams_id, tray_id, spool_id) -> (first_seen, last_seen)
  31. _held: dict[tuple, tuple[float, float]] = {}
  32. _recheck_tasks: dict[int, asyncio.Task] = {}
  33. _recheck: Callable[[int], Awaitable[None]] | None = None
  34. # Indirection so tests can move the clock without touching time.monotonic
  35. # itself, which the event loop reads too.
  36. _now = time.monotonic
  37. def set_recheck(callback: Callable[[int], Awaitable[None]] | None) -> None:
  38. """Register the coroutine that re-runs the cleanup passes for a printer."""
  39. global _recheck
  40. _recheck = callback
  41. def removal_confirmed(printer_id: int, key: tuple) -> bool:
  42. """Has this slot looked empty for the whole grace period?
  43. ``key`` is ``(scope, ams_id, tray_id, spool_id)``, the spool id being the
  44. inventory spool or the Spoolman spool linked to the slot. Returns False while the
  45. removal is being held, and schedules a re-check for when it falls due.
  46. """
  47. full_key = (printer_id, *key)
  48. now = _now()
  49. first_seen, last_seen = _held.get(full_key, (now, now))
  50. if now - last_seen > _STALE_AFTER:
  51. first_seen = now
  52. _held[full_key] = (first_seen, now)
  53. if now - first_seen >= GRACE_SECONDS:
  54. return True
  55. _schedule_recheck(printer_id, GRACE_SECONDS - (now - first_seen))
  56. return False
  57. def is_held(printer_id: int, key: tuple) -> bool:
  58. """Is a removal already being held for this slot?
  59. Lets a caller tell a spool swap from a spool the AMS merely cannot read.
  60. A slot that reports occupied-but-blank is normally kept (#3100), but if it
  61. was reported *empty* first, a spool came out and another went in -- that
  62. keeps the hold running instead of cancelling it.
  63. """
  64. entry = _held.get((printer_id, *key))
  65. return entry is not None and _now() - entry[1] <= _STALE_AFTER
  66. def forget_slot(printer_id: int, ams_id: int, tray_id: int) -> None:
  67. """Drop any hold on a slot a spool has just been assigned or linked to.
  68. A new assignment is fresher evidence than any empty report before it. It
  69. can carry the same key as the hold -- the same spool put back and assigned
  70. again -- so without this the user's own assignment could run out the old
  71. clock and be deleted.
  72. """
  73. for full_key in [k for k in _held if k[0] == printer_id and k[2] == ams_id and k[3] == tray_id]:
  74. del _held[full_key]
  75. def settle(printer_id: int, scope: str, held_keys: set[tuple]) -> None:
  76. """Forget holds in ``scope`` that this pass did not hold again.
  77. Called at the end of each pass with the keys it held. Anything else --
  78. a slot that reads normally again, a removal just confirmed and unlinked,
  79. an assignment that went away by other means -- no longer needs its clock.
  80. """
  81. for full_key in [k for k in _held if k[0] == printer_id and k[1] == scope and k[1:] not in held_keys]:
  82. del _held[full_key]
  83. def _schedule_recheck(printer_id: int, delay: float) -> None:
  84. if _recheck is None:
  85. return
  86. task = _recheck_tasks.get(printer_id)
  87. if task is not None and not task.done():
  88. return
  89. try:
  90. loop = asyncio.get_running_loop()
  91. except RuntimeError:
  92. return
  93. _recheck_tasks[printer_id] = loop.create_task(_run_recheck(printer_id, delay))
  94. async def _run_recheck(printer_id: int, delay: float) -> None:
  95. await asyncio.sleep(max(delay, 0) + 1)
  96. # Drop the handle first, so a hold the re-check itself renews can schedule
  97. # the next one.
  98. _recheck_tasks.pop(printer_id, None)
  99. callback = _recheck
  100. if callback is None:
  101. return
  102. try:
  103. await callback(printer_id)
  104. except Exception:
  105. logger.exception("Held slot unlink re-check failed for printer %s", printer_id)
  106. def reset() -> None:
  107. """Drop every hold and cancel pending re-checks (shutdown, tests)."""
  108. for task in _recheck_tasks.values():
  109. try:
  110. task.cancel()
  111. except RuntimeError:
  112. # Its event loop is already closed; the task can never run.
  113. pass
  114. _recheck_tasks.clear()
  115. _held.clear()