| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- """A tag read in a slot takes the spool out of its other slots (#3427).
- A spool assigned by hand to printer A's empty slot, then loaded into printer B,
- stayed assigned to both once B read its tag: A's slot was configured from, and
- charged to, a spool sitting in B. A manual move already took a spool out of its
- other slots (#1574); the RFID auto-assign and the Spoolman AMS sync did not.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services import spool_relocation
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- UUID = "2E5476BB23AA431296203D76D312DA6F"
- TAG = "0D19333900000100"
- def _tray(tray_id: int, *, tagged: bool = True) -> dict:
- return {
- "id": tray_id,
- "tray_type": "PLA",
- "tray_color": "AFB1AEFF",
- "tray_info_idx": "GFA10",
- "tray_uuid": UUID if tagged else "0" * 32,
- "tag_uid": TAG if tagged else "0" * 16,
- "remain": 100,
- }
- def _status(by_printer: dict[int, list], *, wrapped: bool = False):
- """``get_status`` answering each printer with its own AMS report: the list
- the MQTT client stores, or with ``wrapped`` the push's ``{"ams": [...]}``."""
- return lambda pid: SimpleNamespace(
- raw_data={"ams": {"ams": by_printer.get(pid, [])} if wrapped else by_printer.get(pid, [])},
- state="IDLE",
- connected=True,
- )
- async def _rows(db_session, model=SpoolAssignment) -> set[tuple[int, int, int]]:
- db_session.expire_all()
- rows = (await db_session.execute(select(model))).scalars().all()
- return {(r.printer_id, r.ams_id, r.tray_id) for r in rows}
- async def test_a_tag_read_on_printer_b_releases_printer_as_slot(test_engine, db_session, printer_factory):
- """The reporter's case, through the real AMS-update handler."""
- from backend.app.main import on_ams_change
- a_id = (await printer_factory()).id
- b_id = (await printer_factory()).id
- spool = Spool(material="PLA", label_weight=1000, weight_used=0, rgba="AFB1AEFF", tray_uuid=UUID, tag_uid=TAG)
- db_session.add(spool)
- await db_session.flush()
- spool_id = spool.id
- # Assigned by hand to A's empty slot, waiting for a spool.
- db_session.add(SpoolAssignment(spool_id=spool_id, printer_id=a_id, ams_id=0, tray_id=1))
- await db_session.commit()
- b_ams = [{"id": 0, "tray": [_tray(0)]}]
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- status = _status({b_id: b_ams}, wrapped=True)
- with (
- patch("backend.app.main.async_session", real_db),
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.main.printer_manager") as pm,
- patch("backend.app.services.printer_manager.printer_manager.get_status", status),
- patch("backend.app.main.mqtt_relay") as relay,
- patch("backend.app.main.ws_manager") as ws,
- patch("backend.app.services.spool_relocation.ws_manager") as relocation_ws,
- ):
- pm.get_printer.return_value = MagicMock(name="X1C", serial_number="B")
- pm.get_status.side_effect = status
- pm.get_client.return_value = MagicMock()
- pm.get_model.return_value = "X1C"
- relay.on_ams_change = AsyncMock()
- ws.send_printer_status = AsyncMock()
- ws.broadcast = AsyncMock()
- relocation_ws.broadcast = AsyncMock()
- await on_ams_change(b_id, b_ams)
- assert await _rows(db_session) == {(b_id, 0, 0)}
- relocation_ws.broadcast.assert_awaited_once_with(
- {"type": "spool_assignment_changed", "printer_id": a_id, "ams_id": 0, "tray_id": 1}
- )
- async def test_a_spool_matched_by_material_is_not_taken_from_its_slot(test_engine, db_session, printer_factory):
- """An unknown tag linked to an untagged spool of the same filament proves
- nothing about where that spool is: two identical rolls, one in each printer."""
- from backend.app.main import on_ams_change
- a_id = (await printer_factory()).id
- b_id = (await printer_factory()).id
- spool = Spool(material="PLA", brand="Bambu Lab", label_weight=1000, weight_used=0, rgba="AFB1AEFF")
- db_session.add(spool)
- await db_session.flush()
- spool_id = spool.id
- db_session.add(SpoolAssignment(spool_id=spool_id, printer_id=a_id, ams_id=0, tray_id=1))
- await db_session.commit()
- b_ams = [{"id": 0, "tray": [_tray(0)]}]
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- status = _status({b_id: b_ams})
- with (
- patch("backend.app.main.async_session", real_db),
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.main.printer_manager") as pm,
- patch("backend.app.services.printer_manager.printer_manager.get_status", status),
- patch("backend.app.main.mqtt_relay") as relay,
- patch("backend.app.main.ws_manager") as ws,
- patch("backend.app.services.spool_relocation.ws_manager") as relocation_ws,
- ):
- pm.get_printer.return_value = MagicMock(name="X1C", serial_number="B")
- pm.get_status.side_effect = status
- pm.get_client.return_value = MagicMock()
- pm.get_model.return_value = "X1C"
- relay.on_ams_change = AsyncMock()
- ws.send_printer_status = AsyncMock()
- ws.broadcast = AsyncMock()
- relocation_ws.broadcast = AsyncMock()
- await on_ams_change(b_id, b_ams)
- db_session.expire_all()
- linked = (await db_session.execute(select(Spool).where(Spool.id == spool_id))).scalar_one()
- assert linked.tray_uuid == UUID # the match did happen...
- assert (a_id, 0, 1) in await _rows(db_session) # ...and A keeps its spool
- class TestReleaseOtherSlots:
- async def _setup(self, db_session, printer_factory, slots):
- printers = [(await printer_factory()).id for _ in range(2)]
- spool = Spool(material="PLA", label_weight=1000, weight_used=0)
- db_session.add(spool)
- await db_session.flush()
- spool_id = spool.id
- for printer_idx, ams_id, tray_id in slots:
- db_session.add(
- SpoolAssignment(spool_id=spool_id, printer_id=printers[printer_idx], ams_id=ams_id, tray_id=tray_id)
- )
- await db_session.commit()
- return printers, spool_id
- async def _release(self, test_engine, reports, spool_id, keep, inventory=spool_relocation.INTERNAL):
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- with (
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
- patch("backend.app.services.spool_relocation.ws_manager") as ws,
- ):
- ws.broadcast = AsyncMock()
- return await spool_relocation.release_other_slots(inventory, spool_id, keep)
- async def test_releases_every_other_slot(self, test_engine, db_session, printer_factory):
- (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (0, 1, 2), (1, 0, 0)])
- vacated = await self._release(test_engine, {b: [{"id": 0, "tray": [_tray(0)]}]}, spool_id, (b, 0, 0))
- assert sorted(vacated) == [(a, 0, 1), (a, 1, 2)]
- assert await _rows(db_session) == {(b, 0, 0)}
- async def test_a_slot_reporting_the_same_tag_keeps_its_assignment(self, test_engine, db_session, printer_factory):
- """One tag in two slots is a misread: neither takes the other's row."""
- (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
- reports = {a: [{"id": 0, "tray": [_tray(0), _tray(1)]}], b: [{"id": 0, "tray": [_tray(0)]}]}
- assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
- assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
- async def test_nothing_without_a_tag_in_the_kept_slot(self, test_engine, db_session, printer_factory):
- (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
- reports = {b: [{"id": 0, "tray": [_tray(0, tagged=False)]}]}
- assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
- assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
- async def test_nothing_once_the_kept_slot_holds_another_spool(self, test_engine, db_session, printer_factory):
- """Reassigned since the read: the old read decides nothing."""
- (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1)])
- other = Spool(material="PLA", label_weight=1000, weight_used=0)
- db_session.add(other)
- await db_session.flush()
- db_session.add(SpoolAssignment(spool_id=other.id, printer_id=b, ams_id=0, tray_id=0))
- await db_session.commit()
- reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
- assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
- assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
- async def test_external_slot_is_read_from_vt_tray(self, test_engine, db_session, printer_factory):
- (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 255, 0)])
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- status = lambda pid: SimpleNamespace(raw_data={"vt_tray": [{**_tray(0), "id": "254"}]} if pid == b else {}) # noqa: E731
- with (
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.services.printer_manager.printer_manager.get_status", status),
- patch("backend.app.services.spool_relocation.ws_manager") as ws,
- ):
- ws.broadcast = AsyncMock()
- vacated = await spool_relocation.release_other_slots(spool_relocation.INTERNAL, spool_id, (b, 255, 0))
- assert vacated == [(a, 0, 1)]
- async def test_takes_the_move_lock_then_printer_locks_in_id_order(self, test_engine, db_session, printer_factory):
- """A move's order (#1574): any other order can deadlock against one."""
- (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
- taken = []
- real_move, real_printer = spool_relocation.get_spool_move_lock, spool_relocation.get_ams_assignment_lock
- def move_lock(inventory, spool_id):
- taken.append(("spool", spool_id))
- return real_move(inventory, spool_id)
- def printer_lock(pid):
- taken.append(("printer", pid))
- return real_printer(pid)
- with (
- patch("backend.app.services.spool_relocation.get_spool_move_lock", move_lock),
- patch("backend.app.services.spool_relocation.get_ams_assignment_lock", printer_lock),
- ):
- await self._release(test_engine, {b: [{"id": 0, "tray": [_tray(0)]}]}, spool_id, (b, 0, 0))
- assert taken == [("spool", spool_id), ("printer", min(a, b)), ("printer", max(a, b))]
- async def test_spoolman_sync_releases_the_spools_other_slots(test_engine, db_session, printer_factory):
- """Parity: the Spoolman AMS sync writes the slot it read the tag in and
- takes the spool out of its other slots."""
- from backend.app.services.spoolman_slot_sync import write_slot_results
- a = (await printer_factory()).id
- b = (await printer_factory()).id
- db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
- await db_session.commit()
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
- with (
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
- patch("backend.app.services.spool_relocation.ws_manager") as ws,
- patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
- ):
- ws.broadcast = AsyncMock()
- sync_ws.broadcast = AsyncMock()
- async with real_db() as db:
- await write_slot_results(db, {(a, 0, 1): 42}, [(b, 0, 0, 42)], [])
- assert await _rows(db_session, SpoolmanSlotAssignment) == {(b, 0, 0)}
- async def test_spoolman_sync_without_a_slot_snapshot_releases_too(test_engine, db_session, printer_factory):
- """The write that runs when the slot table could not be read first."""
- from backend.app.services.spoolman_slot_sync import write_slot_results
- a = (await printer_factory()).id
- b = (await printer_factory()).id
- db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
- await db_session.commit()
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
- with (
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
- patch("backend.app.services.spool_relocation.ws_manager") as ws,
- patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
- ):
- ws.broadcast = AsyncMock()
- sync_ws.broadcast = AsyncMock()
- async with real_db() as db:
- await write_slot_results(db, None, [(b, 0, 0, 42)], [])
- assert await _rows(db_session, SpoolmanSlotAssignment) == {(b, 0, 0)}
- async def test_spoolman_sync_of_an_untagged_slot_releases_nothing(test_engine, db_session, printer_factory):
- from backend.app.services.spoolman_slot_sync import write_slot_results
- a = (await printer_factory()).id
- b = (await printer_factory()).id
- db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
- await db_session.commit()
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- reports = {b: [{"id": 0, "tray": [_tray(0, tagged=False)]}]}
- with (
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
- patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
- ):
- sync_ws.broadcast = AsyncMock()
- async with real_db() as db:
- await write_slot_results(db, None, [(b, 0, 0, 42)], [])
- assert await _rows(db_session, SpoolmanSlotAssignment) == {(a, 0, 1), (b, 0, 0)}
- async def test_spoolman_release_clears_the_old_slots_fallback_tag(test_engine, db_session, printer_factory):
- """As a Spoolman move does: a spool linked to an untagged slot carries that
- slot's fallback tag, and usage tracking would keep finding it there."""
- from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
- printer_a = await printer_factory()
- a, serial_a = printer_a.id, printer_a.serial_number
- b = (await printer_factory()).id
- db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
- db_session.add(SpoolmanSlotAssignment(printer_id=b, ams_id=0, tray_id=0, spoolman_spool_id=42))
- await db_session.commit()
- fallback = get_fallback_spool_tag_for_slot(serial_a, 0, 1)
- client = MagicMock()
- client.get_spool = AsyncMock(return_value={"id": 42, "extra": {"tag": f'"{fallback}"'}})
- client.merge_spool_extra = AsyncMock()
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- with (
- patch("backend.app.core.database.async_session", real_db),
- patch(
- "backend.app.services.printer_manager.printer_manager.get_status",
- _status({b: [{"id": 0, "tray": [_tray(0)]}]}),
- ),
- patch("backend.app.services.spool_relocation.ws_manager") as ws,
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
- ):
- ws.broadcast = AsyncMock()
- vacated = await spool_relocation.release_other_slots(spool_relocation.SPOOLMAN, 42, (b, 0, 0))
- assert vacated == [(a, 0, 1)]
- client.merge_spool_extra.assert_awaited_once_with(42, {"tag": '""'})
|