spool_relocation.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Taking a spool out of its other slots once an AMS has read its tag (#3427).
  2. A tag read in a slot says where the physical spool is, so any other slot the
  3. spool is still assigned to is stale. The RFID auto-assign and the Spoolman AMS
  4. sync wrote the slot they read and left those rows alone: a spool assigned by
  5. hand to printer A's empty slot and then loaded into printer B stayed assigned
  6. to both, and A's slot was configured from, and charged to, a spool in B.
  7. This does what a manual move (#1574) does to the other slots, with the same
  8. locks in the same order: the spool's move lock, then, for the internal
  9. inventory, each holding printer's assignment lock by id. It must run after the
  10. caller has let go of its own printer's lock; taking another printer's lock
  11. while holding one is the reverse of a move's order.
  12. The removed slots get no MQTT command and keep what the printer has set, as a
  13. manual unassign does. A Spoolman spool also loses a vacated slot's fallback
  14. tag, as in a Spoolman move. Callers only pass spools already known by the tag
  15. read: a material/colour match proves nothing about where a spool is.
  16. """
  17. from __future__ import annotations
  18. import logging
  19. from contextlib import AsyncExitStack
  20. from sqlalchemy import select
  21. from backend.app.core.websocket import ws_manager
  22. from backend.app.models.spool_assignment import SpoolAssignment
  23. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  24. from backend.app.services.ams_assignment_lock import get_ams_assignment_lock, get_spool_move_lock
  25. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  26. logger = logging.getLogger(__name__)
  27. Slot = tuple[int, int, int] # (printer_id, ams_id, tray_id)
  28. INTERNAL = "inventory"
  29. SPOOLMAN = "spoolman"
  30. def _live_tray(printer_id: int, ams_id: int, tray_id: int) -> dict | None:
  31. """The tray the printer reports for a slot, or None."""
  32. from backend.app.services.printer_manager import printer_manager
  33. state = printer_manager.get_status(printer_id)
  34. raw = getattr(state, "raw_data", None) if state is not None else None
  35. if not isinstance(raw, dict):
  36. return None
  37. if ams_id == 255:
  38. vt_trays = raw.get("vt_tray") or []
  39. for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
  40. if isinstance(tray, dict) and str(tray.get("id", 254)) == str(254 + tray_id):
  41. return tray
  42. return None
  43. units = raw.get("ams") or []
  44. if isinstance(units, dict): # also stored as the push's {"ams": [...]}
  45. units = units.get("ams") or []
  46. for unit in units:
  47. if not isinstance(unit, dict) or str(unit.get("id")) != str(ams_id):
  48. continue
  49. for tray in unit.get("tray") or []:
  50. if isinstance(tray, dict) and str(tray.get("id")) == str(tray_id):
  51. return tray
  52. return None
  53. def _tag_ids(tray: dict | None) -> set[str]:
  54. """The tag's usable identifiers (tag_uid, tray_uuid), normalised; empty when untagged."""
  55. if not tray:
  56. return set()
  57. ids = set()
  58. uid = normalize_tag_uid(tray.get("tag_uid") or "")
  59. if uid and uid != "0" * len(uid):
  60. ids.add(f"uid:{uid}")
  61. uuid = normalize_tray_uuid(tray.get("tray_uuid") or "")
  62. if uuid and uuid != "0" * len(uuid):
  63. ids.add(f"uuid:{uuid}")
  64. return ids
  65. async def _clear_fallback_tag(db, spool_id: int, vacated: list[Slot]) -> None:
  66. """Clear the Spoolman spool's extra.tag when it is a vacated slot's fallback tag.
  67. As a Spoolman move does (#1574): the tag lookup, which usage tracking asks
  68. before the slot table, would otherwise keep finding the spool for the old
  69. slot. Best effort: the rows are already gone, and a failure is logged.
  70. """
  71. from backend.app.api.routes.spoolman_inventory import _clear_vacated_slot_fallback_tag
  72. from backend.app.services.spoolman import get_spoolman_client
  73. try:
  74. client = await get_spoolman_client()
  75. spool = await client.get_spool(spool_id) if client else None
  76. if client and spool:
  77. await _clear_vacated_slot_fallback_tag(client, db, spool_id=spool_id, spool=spool, vacated=vacated)
  78. except Exception as e:
  79. logger.warning("Could not clear the old slot's fallback tag from Spoolman spool %s: %s", spool_id, e)
  80. async def release_other_slots(inventory: str, spool_id: int, keep: Slot) -> list[Slot]:
  81. """Unassign ``spool_id`` from every slot but ``keep``, where a tag was just read.
  82. Only acts while ``keep`` reports a tag and still holds the spool. A slot
  83. whose printer reports the same tag right now keeps its assignment: one tag
  84. in two slots is a misread, and taking turns removing each other would be
  85. worse than leaving both. Returns the slots it removed, already announced.
  86. """
  87. from backend.app.core.database import async_session
  88. tag = _tag_ids(_live_tray(*keep))
  89. if not tag:
  90. return []
  91. model = SpoolAssignment if inventory == INTERNAL else SpoolmanSlotAssignment
  92. spool_col = SpoolAssignment.spool_id if inventory == INTERNAL else SpoolmanSlotAssignment.spoolman_spool_id
  93. async def _holders() -> set[int]:
  94. async with async_session() as db:
  95. rows = await db.execute(select(model.printer_id).where(spool_col == spool_id).distinct())
  96. return set(rows.scalars())
  97. # Spoolman slots have no printer lock to take, as a Spoolman move takes none.
  98. locked = ({keep[0]} | await _holders()) if inventory == INTERNAL else set()
  99. vacated: list[Slot] = []
  100. while True:
  101. async with AsyncExitStack() as locks:
  102. await locks.enter_async_context(get_spool_move_lock(inventory, spool_id))
  103. for pid in sorted(locked):
  104. await locks.enter_async_context(get_ams_assignment_lock(pid))
  105. async with async_session() as db:
  106. rows = (await db.execute(select(model).where(spool_col == spool_id))).scalars().all()
  107. if inventory == INTERNAL and not {row.printer_id for row in rows} <= locked:
  108. locked |= {row.printer_id for row in rows}
  109. continue
  110. if keep not in {(row.printer_id, row.ams_id, row.tray_id) for row in rows}:
  111. # Reassigned since the read: whatever holds the slot now decides.
  112. return []
  113. for row in rows:
  114. slot = (row.printer_id, row.ams_id, row.tray_id)
  115. if slot == keep or _tag_ids(_live_tray(*slot)) & tag:
  116. continue
  117. vacated.append(slot)
  118. await db.delete(row)
  119. await db.commit()
  120. if inventory == SPOOLMAN and vacated:
  121. await _clear_fallback_tag(db, spool_id, vacated)
  122. break
  123. if vacated:
  124. logger.info(
  125. "Tag of spool %s read on printer %d AMS%d-T%d: unassigned it from %s",
  126. spool_id,
  127. *keep,
  128. ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
  129. )
  130. for printer_id, ams_id, tray_id in vacated:
  131. await ws_manager.broadcast(
  132. {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
  133. )
  134. return vacated