From 8a26e7d753b7904442fc38493f3e1bdf0b6d868b Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 25 Jun 2026 09:57:15 +0200 Subject: [PATCH] fix(inventory): stop popping the unknown-tag modal for slots with no RFID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 7cb905a follow-up mounted the global unknown-tag modal listener, which turned an existing always-on broadcast for no-tag slots from a silent no-op into a perpetual popup loop — every push for a slot with a generic non-RFID spool (or zero-filled tag) re-prompted, and confirming each one created a fresh ghost spool with an empty tag. - main.py on_ams_change: drop the no-tag else-branch broadcast. No identity, no prompt; the slot stays unassigned until a real tag is read. - inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no usable tag_uid / tray_uuid so stale frontends can't recreate the ghost spool by re-confirming a queued prompt. - test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty string). --- backend/app/api/routes/inventory.py | 8 +++ backend/app/api/routes/spoolman.py | 7 ++ backend/app/main.py | 16 +---- .../test_inventory_from_slot_no_tag.py | 69 +++++++++++++++++++ 4 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 backend/tests/integration/test_inventory_from_slot_no_tag.py diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index aa4662a05..75c327360 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -2457,6 +2457,14 @@ async def create_spool_from_slot( if not tray or not tray.get("tray_type"): raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data") + # Guard against ghost-spool creation: a slot without any RFID tag has no + # stable identity, so creating an inventory row would just duplicate on + # every confirm and never re-link to the physical spool. + from backend.app.services.spool_tag_matcher import is_valid_tag + + if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")): + raise HTTPException(status_code=400, detail="Slot has no RFID tag") + spool = await create_spool_from_tray(db, tray) await auto_assign_spool( req.printer_id, diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index 847cfbc57..b9549c4ca 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -1207,6 +1207,13 @@ async def create_spool_from_slot( if not tray: raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data") + # Same ghost-spool guard as the inventory route: no tag → no stable + # identity → confirm would just create a fresh Spoolman row per push. + from backend.app.services.spool_tag_matcher import is_valid_tag + + if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""): + raise HTTPException(status_code=400, detail="Slot has no RFID tag") + sync_result = await client.sync_ams_tray( tray, printer.name, diff --git a/backend/app/main.py b/backend/app/main.py index 942efc581..4aa371bb9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1819,19 +1819,9 @@ async def on_ams_change(printer_id: int, ams_data: list): tray_sub_brands=tray.get("tray_sub_brands"), tray_count=len(ams_unit.get("tray", [])), ) - else: - # No tag at all — let user choose from inventory - await _broadcast_unknown_tag( - printer_id=printer_id, - ams_id=ams_id, - tray_id=tray_id, - tag_uid="", - tray_uuid="", - tray_type=tray.get("tray_type"), - tray_color=tray.get("tray_color"), - tray_sub_brands=tray.get("tray_sub_brands"), - tray_count=len(ams_unit.get("tray", [])), - ) + # No-tag slots (generic non-RFID filament) are left alone: + # nothing to identify, prompting "+ Add" would just create + # ghost spools with empty tags on every confirm. except Exception as e: logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True) diff --git a/backend/tests/integration/test_inventory_from_slot_no_tag.py b/backend/tests/integration/test_inventory_from_slot_no_tag.py new file mode 100644 index 000000000..dd01e3a98 --- /dev/null +++ b/backend/tests/integration/test_inventory_from_slot_no_tag.py @@ -0,0 +1,69 @@ +"""No-tag guard on POST /api/v1/inventory/spools/from-slot. + +A slot that has tray_type set but no RFID tag (generic third-party PLA the +user manually configured on the printer panel) has no stable identity, so +auto-creating an inventory row for it would just duplicate on every confirm +without ever re-linking to the physical spool. Verify the endpoint refuses +that case with 400. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from httpx import AsyncClient + + +def _mock_status_for_tray(ams_id: int, tray_id: int, tray: dict) -> MagicMock: + status = MagicMock() + status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}} + return status + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_from_slot_rejects_no_tag(async_client: AsyncClient, printer_factory): + printer = await printer_factory(name="X1C-no-tag") + + no_tag_tray = { + "tray_type": "PLA", + "tray_color": "FF0000FF", + "tag_uid": "0000000000000000", + "tray_uuid": "00000000000000000000000000000000", + } + + with patch( + "backend.app.services.printer_manager.printer_manager.get_status", + return_value=_mock_status_for_tray(0, 1, no_tag_tray), + ): + resp = await async_client.post( + "/api/v1/inventory/spools/from-slot", + json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1}, + ) + + assert resp.status_code == 400 + assert "RFID tag" in resp.json()["detail"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_from_slot_rejects_empty_tag_strings(async_client: AsyncClient, printer_factory): + """Empty strings (not zero-filled) must also be refused.""" + printer = await printer_factory(name="X1C-empty-tag") + + empty_tag_tray = { + "tray_type": "PETG", + "tray_color": "00FF00FF", + "tag_uid": "", + "tray_uuid": "", + } + + with patch( + "backend.app.services.printer_manager.printer_manager.get_status", + return_value=_mock_status_for_tray(0, 2, empty_tag_tray), + ): + resp = await async_client.post( + "/api/v1/inventory/spools/from-slot", + json={"printer_id": printer.id, "ams_id": 0, "tray_id": 2}, + ) + + assert resp.status_code == 400