mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
145 lines
5.7 KiB
Python
145 lines
5.7 KiB
Python
"""Hold an automatic slot unlink until "the spool is gone" has lasted.
|
|
|
|
Both inventory backends unlink a slot's spool when the AMS says the slot is
|
|
empty: the built-in inventory deletes the ``spool_assignment`` row, Spoolman
|
|
mode deletes the ``spoolman_slot_assignments`` row. Until #3186 one MQTT push
|
|
was enough. An idle X1 Carbon on X1Plus firmware sent a push that cleared an
|
|
entire AMS unit -- presence bits off, colour and type blank -- and four saved
|
|
assignments were deleted in the same instant. The spools never moved. When
|
|
the next push reported them again, nothing brought the rows back, and the
|
|
identity of a non-RFID spool exists nowhere but in that row.
|
|
|
|
A spool that really was taken out stays out, so the evidence is cheap to
|
|
confirm: note when a slot first looks empty, keep the row, and unlink only if
|
|
it still looks empty ``GRACE_SECONDS`` later. A slot that reads normally again
|
|
in between is forgotten. Evidence of a *different* spool -- another colour or
|
|
type, another Bambu tag -- is not held here; callers unlink that immediately.
|
|
|
|
The unlink passes run only when the AMS hash changes, so a slot that goes
|
|
empty and stays empty might never be looked at again. Holding a removal
|
|
therefore also schedules one re-check per printer, which re-runs just the
|
|
cleanup passes against the printer's current AMS state.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GRACE_SECONDS = 120.0
|
|
|
|
# A hold that nobody re-observed for this long is out of date -- the pass that
|
|
# would have seen it did not run (Spoolman unreachable, printer offline) -- so
|
|
# the next sighting starts the clock again instead of confirming at once.
|
|
_STALE_AFTER = 2 * GRACE_SECONDS
|
|
|
|
# (printer_id, scope, ams_id, tray_id, spool_id) -> (first_seen, last_seen)
|
|
_held: dict[tuple, tuple[float, float]] = {}
|
|
_recheck_tasks: dict[int, asyncio.Task] = {}
|
|
_recheck: Callable[[int], Awaitable[None]] | None = None
|
|
|
|
# Indirection so tests can move the clock without touching time.monotonic
|
|
# itself, which the event loop reads too.
|
|
_now = time.monotonic
|
|
|
|
|
|
def set_recheck(callback: Callable[[int], Awaitable[None]] | None) -> None:
|
|
"""Register the coroutine that re-runs the cleanup passes for a printer."""
|
|
global _recheck
|
|
_recheck = callback
|
|
|
|
|
|
def removal_confirmed(printer_id: int, key: tuple) -> bool:
|
|
"""Has this slot looked empty for the whole grace period?
|
|
|
|
``key`` is ``(scope, ams_id, tray_id, spool_id)``, the spool id being the
|
|
inventory spool or the Spoolman spool linked to the slot. Returns False while the
|
|
removal is being held, and schedules a re-check for when it falls due.
|
|
"""
|
|
full_key = (printer_id, *key)
|
|
now = _now()
|
|
first_seen, last_seen = _held.get(full_key, (now, now))
|
|
if now - last_seen > _STALE_AFTER:
|
|
first_seen = now
|
|
_held[full_key] = (first_seen, now)
|
|
if now - first_seen >= GRACE_SECONDS:
|
|
return True
|
|
_schedule_recheck(printer_id, GRACE_SECONDS - (now - first_seen))
|
|
return False
|
|
|
|
|
|
def is_held(printer_id: int, key: tuple) -> bool:
|
|
"""Is a removal already being held for this slot?
|
|
|
|
Lets a caller tell a spool swap from a spool the AMS merely cannot read.
|
|
A slot that reports occupied-but-blank is normally kept (#3100), but if it
|
|
was reported *empty* first, a spool came out and another went in -- that
|
|
keeps the hold running instead of cancelling it.
|
|
"""
|
|
entry = _held.get((printer_id, *key))
|
|
return entry is not None and _now() - entry[1] <= _STALE_AFTER
|
|
|
|
|
|
def forget_slot(printer_id: int, ams_id: int, tray_id: int) -> None:
|
|
"""Drop any hold on a slot a spool has just been assigned or linked to.
|
|
|
|
A new assignment is fresher evidence than any empty report before it. It
|
|
can carry the same key as the hold -- the same spool put back and assigned
|
|
again -- so without this the user's own assignment could run out the old
|
|
clock and be deleted.
|
|
"""
|
|
for full_key in [k for k in _held if k[0] == printer_id and k[2] == ams_id and k[3] == tray_id]:
|
|
del _held[full_key]
|
|
|
|
|
|
def settle(printer_id: int, scope: str, held_keys: set[tuple]) -> None:
|
|
"""Forget holds in ``scope`` that this pass did not hold again.
|
|
|
|
Called at the end of each pass with the keys it held. Anything else --
|
|
a slot that reads normally again, a removal just confirmed and unlinked,
|
|
an assignment that went away by other means -- no longer needs its clock.
|
|
"""
|
|
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]:
|
|
del _held[full_key]
|
|
|
|
|
|
def _schedule_recheck(printer_id: int, delay: float) -> None:
|
|
if _recheck is None:
|
|
return
|
|
task = _recheck_tasks.get(printer_id)
|
|
if task is not None and not task.done():
|
|
return
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return
|
|
_recheck_tasks[printer_id] = loop.create_task(_run_recheck(printer_id, delay))
|
|
|
|
|
|
async def _run_recheck(printer_id: int, delay: float) -> None:
|
|
await asyncio.sleep(max(delay, 0) + 1)
|
|
# Drop the handle first, so a hold the re-check itself renews can schedule
|
|
# the next one.
|
|
_recheck_tasks.pop(printer_id, None)
|
|
callback = _recheck
|
|
if callback is None:
|
|
return
|
|
try:
|
|
await callback(printer_id)
|
|
except Exception:
|
|
logger.exception("Held slot unlink re-check failed for printer %s", printer_id)
|
|
|
|
|
|
def reset() -> None:
|
|
"""Drop every hold and cancel pending re-checks (shutdown, tests)."""
|
|
for task in _recheck_tasks.values():
|
|
try:
|
|
task.cancel()
|
|
except RuntimeError:
|
|
# Its event loop is already closed; the task can never run.
|
|
pass
|
|
_recheck_tasks.clear()
|
|
_held.clear()
|