| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- """Taking a spool out of its other slots once an AMS has read its tag (#3427).
- A tag read in a slot says where the physical spool is, so any other slot the
- spool is still assigned to is stale. The RFID auto-assign and the Spoolman AMS
- sync wrote the slot they read and left those rows alone: a spool assigned by
- hand to printer A's empty slot and then loaded into printer B stayed assigned
- to both, and A's slot was configured from, and charged to, a spool in B.
- This does what a manual move (#1574) does to the other slots, with the same
- locks in the same order: the spool's move lock, then, for the internal
- inventory, each holding printer's assignment lock by id. It must run after the
- caller has let go of its own printer's lock; taking another printer's lock
- while holding one is the reverse of a move's order.
- The removed slots get no MQTT command and keep what the printer has set, as a
- manual unassign does. A Spoolman spool also loses a vacated slot's fallback
- tag, as in a Spoolman move. Callers only pass spools already known by the tag
- read: a material/colour match proves nothing about where a spool is.
- """
- from __future__ import annotations
- import logging
- from contextlib import AsyncExitStack
- from sqlalchemy import select
- from backend.app.core.websocket import ws_manager
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services.ams_assignment_lock import get_ams_assignment_lock, get_spool_move_lock
- from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
- logger = logging.getLogger(__name__)
- Slot = tuple[int, int, int] # (printer_id, ams_id, tray_id)
- INTERNAL = "inventory"
- SPOOLMAN = "spoolman"
- def _live_tray(printer_id: int, ams_id: int, tray_id: int) -> dict | None:
- """The tray the printer reports for a slot, or None."""
- from backend.app.services.printer_manager import printer_manager
- state = printer_manager.get_status(printer_id)
- raw = getattr(state, "raw_data", None) if state is not None else None
- if not isinstance(raw, dict):
- return None
- if ams_id == 255:
- vt_trays = raw.get("vt_tray") or []
- for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
- if isinstance(tray, dict) and str(tray.get("id", 254)) == str(254 + tray_id):
- return tray
- return None
- units = raw.get("ams") or []
- if isinstance(units, dict): # also stored as the push's {"ams": [...]}
- units = units.get("ams") or []
- for unit in units:
- if not isinstance(unit, dict) or str(unit.get("id")) != str(ams_id):
- continue
- for tray in unit.get("tray") or []:
- if isinstance(tray, dict) and str(tray.get("id")) == str(tray_id):
- return tray
- return None
- def _tag_ids(tray: dict | None) -> set[str]:
- """The tag's usable identifiers (tag_uid, tray_uuid), normalised; empty when untagged."""
- if not tray:
- return set()
- ids = set()
- uid = normalize_tag_uid(tray.get("tag_uid") or "")
- if uid and uid != "0" * len(uid):
- ids.add(f"uid:{uid}")
- uuid = normalize_tray_uuid(tray.get("tray_uuid") or "")
- if uuid and uuid != "0" * len(uuid):
- ids.add(f"uuid:{uuid}")
- return ids
- async def _clear_fallback_tag(db, spool_id: int, vacated: list[Slot]) -> None:
- """Clear the Spoolman spool's extra.tag when it is a vacated slot's fallback tag.
- As a Spoolman move does (#1574): the tag lookup, which usage tracking asks
- before the slot table, would otherwise keep finding the spool for the old
- slot. Best effort: the rows are already gone, and a failure is logged.
- """
- from backend.app.api.routes.spoolman_inventory import _clear_vacated_slot_fallback_tag
- from backend.app.services.spoolman import get_spoolman_client
- try:
- client = await get_spoolman_client()
- spool = await client.get_spool(spool_id) if client else None
- if client and spool:
- await _clear_vacated_slot_fallback_tag(client, db, spool_id=spool_id, spool=spool, vacated=vacated)
- except Exception as e:
- logger.warning("Could not clear the old slot's fallback tag from Spoolman spool %s: %s", spool_id, e)
- async def release_other_slots(inventory: str, spool_id: int, keep: Slot) -> list[Slot]:
- """Unassign ``spool_id`` from every slot but ``keep``, where a tag was just read.
- Only acts while ``keep`` reports a tag and still holds the spool. A slot
- whose printer reports the same tag right now keeps its assignment: one tag
- in two slots is a misread, and taking turns removing each other would be
- worse than leaving both. Returns the slots it removed, already announced.
- """
- from backend.app.core.database import async_session
- tag = _tag_ids(_live_tray(*keep))
- if not tag:
- return []
- model = SpoolAssignment if inventory == INTERNAL else SpoolmanSlotAssignment
- spool_col = SpoolAssignment.spool_id if inventory == INTERNAL else SpoolmanSlotAssignment.spoolman_spool_id
- async def _holders() -> set[int]:
- async with async_session() as db:
- rows = await db.execute(select(model.printer_id).where(spool_col == spool_id).distinct())
- return set(rows.scalars())
- # Spoolman slots have no printer lock to take, as a Spoolman move takes none.
- locked = ({keep[0]} | await _holders()) if inventory == INTERNAL else set()
- vacated: list[Slot] = []
- while True:
- async with AsyncExitStack() as locks:
- await locks.enter_async_context(get_spool_move_lock(inventory, spool_id))
- for pid in sorted(locked):
- await locks.enter_async_context(get_ams_assignment_lock(pid))
- async with async_session() as db:
- rows = (await db.execute(select(model).where(spool_col == spool_id))).scalars().all()
- if inventory == INTERNAL and not {row.printer_id for row in rows} <= locked:
- locked |= {row.printer_id for row in rows}
- continue
- if keep not in {(row.printer_id, row.ams_id, row.tray_id) for row in rows}:
- # Reassigned since the read: whatever holds the slot now decides.
- return []
- for row in rows:
- slot = (row.printer_id, row.ams_id, row.tray_id)
- if slot == keep or _tag_ids(_live_tray(*slot)) & tag:
- continue
- vacated.append(slot)
- await db.delete(row)
- await db.commit()
- if inventory == SPOOLMAN and vacated:
- await _clear_fallback_tag(db, spool_id, vacated)
- break
- if vacated:
- logger.info(
- "Tag of spool %s read on printer %d AMS%d-T%d: unassigned it from %s",
- spool_id,
- *keep,
- ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
- )
- for printer_id, ams_id, tray_id in vacated:
- await ws_manager.broadcast(
- {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
- )
- return vacated
|