Browse Source

feat(spoolbuddy): prefill Quick Add from a scanned Bambu tag (#2685) (#3390)

Victor Manuel 13 hours ago
parent
commit
ba8ecb820e

+ 11 - 0
backend/app/api/routes/spoolbuddy.py

@@ -42,6 +42,7 @@ from backend.app.schemas.spoolbuddy import (
     WriteTagRequest,
     WriteTagResultRequest,
 )
+from backend.app.services.bambu_tag_blocks import spool_prefill_from_tag
 from backend.app.services.spool_tag_matcher import (
     begins_with_cascade_level_1,
     cascade_level_1_uid,
@@ -774,6 +775,15 @@ async def nfc_tag_scanned(
                 await _backfill_local_tray_uuid(db, spool, req.tag_uid, tray_uuid)
             return {"status": "ok", "matched": True, "spool_id": spool.id}
 
+    # What the tag itself says about the roll, so the kiosk's Quick Add can
+    # create the spool AMS sync would have created, not a blank PLA (#2685)
+    tag_data = None
+    if req.raw_blocks:
+        try:
+            tag_data = await spool_prefill_from_tag(db, req.raw_blocks)
+        except Exception:
+            logger.warning("Could not decode Bambu tag blocks for %s", req.tag_uid, exc_info=True)
+
     await ws_manager.broadcast(
         {
             "type": "spoolbuddy_unknown_tag",
@@ -782,6 +792,7 @@ async def nfc_tag_scanned(
             "tray_uuid": tray_uuid,
             "sak": req.sak,
             "tag_type": req.tag_type,
+            "tag_data": tag_data,
         }
     )
     logger.info(

+ 18 - 0
backend/app/schemas/spoolbuddy.py

@@ -94,6 +94,24 @@ class TagScannedRequest(BaseModel):
     tray_uuid: str | None = Field(None, max_length=32, pattern=r"^[0-9A-Fa-f]*$")
     sak: int | None = None
     tag_type: str | None = Field(None, max_length=50)
+    # Data blocks of a Bambu tag the daemon read, block number -> 32 hex chars
+    # (#2685). Absent when no Bambu read succeeded.
+    raw_blocks: dict[str, str] | None = None
+
+    @field_validator("raw_blocks")
+    @classmethod
+    def validate_raw_blocks(cls, v: dict[str, str] | None) -> dict[str, str] | None:
+        if v is None:
+            return v
+        if len(v) > 16:
+            raise ValueError("raw_blocks holds at most 16 blocks")
+        for key, value in v.items():
+            # MIFARE Classic 1K has blocks 0-63, 16 bytes each
+            if not key.isdigit() or int(key) > 63:
+                raise ValueError(f"raw_blocks key {key!r} is not a block number 0-63")
+            if len(value) != 32 or any(c not in "0123456789abcdefABCDEF" for c in value):
+                raise ValueError(f"raw_blocks block {key} is not 32 hex characters")
+        return v
 
 
 class TagRemovedRequest(BaseModel):

+ 104 - 0
backend/app/services/bambu_tag_blocks.py

@@ -0,0 +1,104 @@
+"""Decode the data blocks of a Bambu Lab filament RFID tag (#2685).
+
+SpoolBuddy reads blocks 1, 2, 4, 5, 6 and 9 of a genuine Bambu tag (MIFARE
+Classic 1K) and sends them as hex. The layout follows the community
+RFID-Tag-Guide; numbers are little-endian. The result uses the field names of
+an AMS tray report, so a roll SpoolBuddy read goes through the same mapping as
+one the AMS reported (``spool_tag_matcher.resolve_tray_fields``).
+"""
+
+import math
+import re
+import struct
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+_HEX_BLOCK = re.compile(r"^[0-9A-Fa-f]{32}$")
+
+# Without these the tag says nothing usable about the roll
+REQUIRED_BLOCKS = (1, 2, 4, 5)
+
+
+def _ascii(data: bytes) -> str:
+    return data.split(b"\x00", 1)[0].decode("ascii", errors="ignore").strip()
+
+
+def _u16(data: bytes, offset: int) -> int:
+    return struct.unpack_from("<H", data, offset)[0]
+
+
+def decode_bambu_tag_blocks(raw_blocks: dict[str, str] | None) -> dict | None:
+    """The tag's contents as AMS tray fields, or None when a needed block is missing or unreadable.
+
+    Block 6 (temperatures) and block 9 (tray UUID) are optional: their fields are
+    left out when the block is absent.
+    """
+    if not raw_blocks:
+        return None
+
+    blocks: dict[int, bytes] = {}
+    for key, value in raw_blocks.items():
+        if str(key).isdigit() and isinstance(value, str) and _HEX_BLOCK.match(value):
+            blocks[int(key)] = bytes.fromhex(value)
+    if any(n not in blocks for n in REQUIRED_BLOCKS):
+        return None
+
+    tray_type = _ascii(blocks[2])
+    if not tray_type:
+        return None
+
+    b1, b5 = blocks[1], blocks[5]
+    tray: dict = {
+        "tray_id_name": _ascii(b1[0:8]),  # variant, e.g. "A00-B08"
+        "tray_info_idx": _ascii(b1[8:16]),  # material / slicer profile, e.g. "GFA00"
+        "tray_type": tray_type,  # "PLA"
+        "tray_sub_brands": _ascii(blocks[4]),  # "PLA Basic"
+        "tray_color": b5[0:4].hex().upper(),  # RRGGBBAA
+    }
+
+    label_weight = _u16(b5, 4)
+    if label_weight:
+        tray["tray_weight"] = label_weight
+    diameter = struct.unpack_from("<f", b5, 8)[0]
+    if math.isfinite(diameter) and 0 < diameter < 5:
+        tray["tray_diameter"] = round(diameter, 2)
+
+    b6 = blocks.get(6)
+    if b6 is not None:
+        for name, offset in (
+            ("drying_temp", 0),
+            ("drying_time", 2),
+            ("bed_temp", 6),
+            ("nozzle_temp_max", 8),
+            ("nozzle_temp_min", 10),
+        ):
+            value = _u16(b6, offset)
+            if value:
+                tray[name] = value
+
+    b9 = blocks.get(9)
+    if b9 is not None and any(b9):
+        tray["tray_uuid"] = b9.hex().upper()
+
+    return tray
+
+
+async def spool_prefill_from_tag(db: AsyncSession, raw_blocks: dict[str, str] | None) -> dict | None:
+    """The spool a scanned Bambu tag describes, for prefilling Quick Add, or None when the tag can't be decoded.
+
+    Holds the ``Spool`` column values AMS auto-creation would set for the same
+    roll, plus the tag's diameter and drying hints for display.
+    """
+    from backend.app.services.spool_tag_matcher import bambu_spool_core_weight, resolve_tray_fields
+
+    tray = decode_bambu_tag_blocks(raw_blocks)
+    if tray is None:
+        return None
+
+    prefill = await resolve_tray_fields(db, tray)
+    prefill["core_weight"], prefill["core_weight_catalog_id"] = await bambu_spool_core_weight(db)
+    prefill["variant_id"] = tray["tray_id_name"] or None
+    prefill["diameter"] = tray.get("tray_diameter")
+    prefill["drying_temp"] = tray.get("drying_temp")
+    prefill["drying_time"] = tray.get("drying_time")
+    return prefill

+ 52 - 31
backend/app/services/spool_tag_matcher.py

@@ -57,20 +57,17 @@ def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
     return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
 
 
-async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
-    """Create a new Spool inventory entry from AMS tray MQTT data.
+async def resolve_tray_fields(db: AsyncSession, tray_data: dict) -> dict:
+    """The spool fields an AMS tray report describes: material, colour, profile, temps, label weight.
 
-    Extracts material, subtype, color, temps, and tag info from the tray dict.
-    Looks up core_weight from the spool catalog if a Bambu Lab entry matches.
+    Shared by AMS auto-creation and SpoolBuddy's Quick Add prefill from a decoded
+    tag (#2685), so a roll gets the same material, colour name and profile
+    whichever reader saw it. Returns a dict of ``Spool`` column values.
     """
-    from backend.app.models.spool_catalog import SpoolCatalogEntry
-
     tray_type = tray_data.get("tray_type", "")  # "PLA"
     tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
     tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
     tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
-    tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", ""))
-    tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", ""))
     tray_info_idx = tray_data.get("tray_info_idx", "")
     nozzle_min = tray_data.get("nozzle_temp_min", 0)
     nozzle_max = tray_data.get("nozzle_temp_max", 0)
@@ -161,6 +158,39 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
                 continue
             break
 
+    # Resolve slicer filament name from builtin table
+    slicer_filament_name = None
+    if tray_info_idx:
+        try:
+            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+            slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
+        except Exception:
+            pass
+        # Fallback: use tray_sub_brands as the display name
+        if not slicer_filament_name and tray_sub_brands:
+            slicer_filament_name = tray_sub_brands
+
+    return {
+        "material": material,
+        "subtype": subtype,
+        "color_name": color_name,
+        "rgba": rgba,
+        "extra_colors": extra_colors,
+        "effect_type": effect_type,
+        "brand": "Bambu Lab",
+        "label_weight": label_weight,
+        "slicer_filament": tray_info_idx or None,
+        "slicer_filament_name": slicer_filament_name,
+        "nozzle_temp_min": int(nozzle_min) if nozzle_min else None,
+        "nozzle_temp_max": int(nozzle_max) if nozzle_max else None,
+    }
+
+
+async def bambu_spool_core_weight(db: AsyncSession) -> tuple[int, int | None]:
+    """The empty-spool weight an RFID roll arrives on, and the catalog row it came from (if any)."""
+    from backend.app.models.spool_catalog import SpoolCatalogEntry
+
     # Look up core weight from the spool catalog by exact name. The previous
     # "Bambu Lab%" prefix query had no matching step and no ORDER BY, so it took
     # whichever of the three Bambu Lab rows the database returned first — High Temp
@@ -185,19 +215,21 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     if catalog_entry:
         core_weight = catalog_entry.weight
         core_weight_catalog_id = catalog_entry.id
+    return core_weight, core_weight_catalog_id
 
-    # Resolve slicer filament name from builtin table
-    slicer_filament_name = None
-    if tray_info_idx:
-        try:
-            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
 
-            slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
-        except Exception:
-            pass
-        # Fallback: use tray_sub_brands as the display name
-        if not slicer_filament_name and tray_sub_brands:
-            slicer_filament_name = tray_sub_brands
+async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
+    """Create a new Spool inventory entry from AMS tray MQTT data.
+
+    Extracts material, subtype, color, temps, and tag info from the tray dict.
+    Looks up core_weight from the spool catalog if a Bambu Lab entry matches.
+    """
+    fields = await resolve_tray_fields(db, tray_data)
+    material, subtype, color_name = fields["material"], fields["subtype"], fields["color_name"]
+    label_weight = fields["label_weight"]
+    tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", ""))
+    tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", ""))
+    core_weight, core_weight_catalog_id = await bambu_spool_core_weight(db)
 
     # Calculate initial weight_used from AMS remain percentage
     remain_raw = tray_data.get("remain")
@@ -223,22 +255,11 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     )
 
     spool = Spool(
-        material=material,
-        subtype=subtype,
-        color_name=color_name,
-        rgba=rgba,
-        extra_colors=extra_colors,
-        effect_type=effect_type,
-        brand="Bambu Lab",
+        **fields,
         material_number=material_number,
-        label_weight=label_weight,
         core_weight=core_weight,
         core_weight_catalog_id=core_weight_catalog_id,
         weight_used=weight_used,
-        slicer_filament=tray_info_idx or None,
-        slicer_filament_name=slicer_filament_name,
-        nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
-        nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
         tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,
         tray_uuid=tray_uuid if tray_uuid and tray_uuid != ZERO_TRAY_UUID else None,
         data_origin="rfid_auto",

+ 104 - 0
backend/tests/integration/test_spoolbuddy_tag_prefill_2685.py

@@ -0,0 +1,104 @@
+"""An unknown Bambu tag scanned on SpoolBuddy carries what its blocks say about the roll (#2685).
+
+The kiosk's Quick Add creates the spool from ``tag_data``, so it must hold the
+same values AMS auto-creation would set for that roll.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.color_catalog import ColorCatalogEntry
+
+API = "/api/v1/spoolbuddy"
+
+# Read from a real Bambu PLA Basic Cyan spool (product 10603)
+CYAN_PLA_BASIC = {
+    "1": "4130302D423038004746413030000000",
+    "2": "504C4100000000000000000000000000",
+    "4": "504C4120426173696300000000000000",
+    "5": "0086D6FFE80300000000E03F00000000",
+    "6": "3700080000000000E600BE0000000000",
+    "9": "36B6C12DB3DA4A39A50C275F510BD399",
+}
+TRAY_UUID = "36B6C12DB3DA4A39A50C275F510BD399"
+
+
+async def _scan(async_client: AsyncClient, **body) -> dict:
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(
+            f"{API}/nfc/tag-scanned",
+            json={"device_id": "sb-1", "tag_uid": "865947E2", "tray_uuid": TRAY_UUID, **body},
+        )
+    assert resp.status_code == 200, resp.text
+    assert resp.json()["matched"] is False
+    return mock_ws.broadcast.call_args[0][0]
+
+
+class TestUnknownTagPrefill:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_blocks_become_the_spool_ams_sync_would_create(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        db_session.add(
+            ColorCatalogEntry(
+                manufacturer="Bambu Lab", color_name="Cyan", hex_color="#0086D6", material="PLA Basic", is_default=True
+            )
+        )
+        await db_session.commit()
+
+        msg = await _scan(async_client, raw_blocks=CYAN_PLA_BASIC)
+
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+        assert msg["tag_data"] == {
+            "material": "PLA",
+            "subtype": "Basic",
+            "color_name": "Cyan",
+            "rgba": "0086D6FF",
+            "extra_colors": None,
+            "effect_type": None,
+            "brand": "Bambu Lab",
+            "label_weight": 1000,
+            "slicer_filament": "GFA00",
+            "slicer_filament_name": "Bambu PLA Basic",
+            "nozzle_temp_min": 190,
+            "nozzle_temp_max": 230,
+            "core_weight": 250,
+            "core_weight_catalog_id": None,
+            "variant_id": "A00-B08",
+            "diameter": 1.75,
+            "drying_temp": 55,
+            "drying_time": 8,
+        }
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_blocks_means_no_tag_data(self, async_client: AsyncClient):
+        """A non-Bambu tag, or an older daemon, keeps today's Quick Add."""
+        msg = await _scan(async_client)
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+        assert msg["tag_data"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_undecodable_blocks_mean_no_tag_data(self, async_client: AsyncClient):
+        """An incomplete read falls back to today's Quick Add instead of a half-filled spool."""
+        incomplete = {k: v for k, v in CYAN_PLA_BASIC.items() if k != "2"}
+        msg = await _scan(async_client, raw_blocks=incomplete)
+        assert msg["tag_data"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_malformed_blocks_are_rejected(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "865947E2", "raw_blocks": {"1": "zz"}},
+            )
+        assert resp.status_code == 422
+        mock_ws.broadcast.assert_not_called()

+ 102 - 0
backend/tests/unit/services/test_bambu_tag_blocks.py

@@ -0,0 +1,102 @@
+"""Decoding Bambu tag blocks sent by SpoolBuddy (#2685)."""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.spoolbuddy import TagScannedRequest
+from backend.app.services.bambu_tag_blocks import decode_bambu_tag_blocks
+
+# Read from a real Bambu PLA Basic Cyan spool (product 10603)
+CYAN_PLA_BASIC = {
+    "1": "4130302D423038004746413030000000",
+    "2": "504C4100000000000000000000000000",
+    "4": "504C4120426173696300000000000000",
+    "5": "0086D6FFE80300000000E03F00000000",
+    "6": "3700080000000000E600BE0000000000",
+    "9": "36B6C12DB3DA4A39A50C275F510BD399",
+}
+
+
+class TestDecodeBambuTagBlocks:
+    def test_real_tag(self):
+        assert decode_bambu_tag_blocks(CYAN_PLA_BASIC) == {
+            "tray_id_name": "A00-B08",
+            "tray_info_idx": "GFA00",
+            "tray_type": "PLA",
+            "tray_sub_brands": "PLA Basic",
+            "tray_color": "0086D6FF",
+            "tray_weight": 1000,
+            "tray_diameter": 1.75,
+            "drying_temp": 55,
+            "drying_time": 8,
+            "nozzle_temp_max": 230,
+            "nozzle_temp_min": 190,
+            "tray_uuid": "36B6C12DB3DA4A39A50C275F510BD399",
+        }
+
+    def test_translucent_colour_keeps_its_alpha(self):
+        """Block 5 is RGBA: a translucent roll's alpha must survive, not be forced opaque."""
+        # Bambu PETG Translucent, Translucent Pink, read on a real SpoolBuddy
+        tray = decode_bambu_tag_blocks({**CYAN_PLA_BASIC, "5": "F9C1BD80E80300000000E03F00000000"})
+        assert tray["tray_color"] == "F9C1BD80"
+        assert tray["tray_weight"] == 1000
+        assert tray["tray_diameter"] == 1.75
+
+    def test_lowercase_hex_is_accepted(self):
+        lower = {k: v.lower() for k, v in CYAN_PLA_BASIC.items()}
+        assert decode_bambu_tag_blocks(lower) == decode_bambu_tag_blocks(CYAN_PLA_BASIC)
+
+    def test_without_block_6_has_no_temperatures(self):
+        blocks = {k: v for k, v in CYAN_PLA_BASIC.items() if k != "6"}
+        tray = decode_bambu_tag_blocks(blocks)
+        assert tray["tray_sub_brands"] == "PLA Basic"
+        assert not {"nozzle_temp_min", "nozzle_temp_max", "drying_temp", "drying_time"} & tray.keys()
+
+    def test_all_zero_block_9_is_no_tray_uuid(self):
+        tray = decode_bambu_tag_blocks({**CYAN_PLA_BASIC, "9": "0" * 32})
+        assert "tray_uuid" not in tray
+
+    def test_missing_required_block_is_not_decoded(self):
+        for missing in ("1", "2", "4", "5"):
+            blocks = {k: v for k, v in CYAN_PLA_BASIC.items() if k != missing}
+            assert decode_bambu_tag_blocks(blocks) is None, missing
+
+    def test_unreadable_required_block_is_not_decoded(self):
+        assert decode_bambu_tag_blocks({**CYAN_PLA_BASIC, "2": "not hex at all, 32 characters!!"}) is None
+        assert decode_bambu_tag_blocks({**CYAN_PLA_BASIC, "5": "0086D6FF"}) is None
+
+    def test_blank_filament_type_is_not_decoded(self):
+        assert decode_bambu_tag_blocks({**CYAN_PLA_BASIC, "2": "0" * 32}) is None
+
+    def test_no_blocks(self):
+        assert decode_bambu_tag_blocks(None) is None
+        assert decode_bambu_tag_blocks({}) is None
+
+    def test_implausible_diameter_and_zero_weight_are_left_out(self):
+        tray = decode_bambu_tag_blocks({**CYAN_PLA_BASIC, "5": "0086D6FF" + "0" * 24})
+        assert "tray_weight" not in tray
+        assert "tray_diameter" not in tray
+
+
+class TestTagScannedRawBlocksValidation:
+    BASE = {"device_id": "sb-1", "tag_uid": "865947E2"}
+
+    def test_absent_is_none(self):
+        assert TagScannedRequest(**self.BASE).raw_blocks is None
+
+    def test_real_blocks_accepted(self):
+        assert TagScannedRequest(**self.BASE, raw_blocks=CYAN_PLA_BASIC).raw_blocks == CYAN_PLA_BASIC
+
+    @pytest.mark.parametrize(
+        "raw_blocks",
+        [
+            {"x": "0" * 32},  # not a block number
+            {"64": "0" * 32},  # past the last block of a 1K tag
+            {"1": "0" * 30},  # short block
+            {"1": "G" * 32},  # not hex
+            {str(n): "0" * 32 for n in range(17)},  # more blocks than are ever sent
+        ],
+    )
+    def test_malformed_blocks_rejected(self, raw_blocks):
+        with pytest.raises(ValidationError):
+            TagScannedRequest(**self.BASE, raw_blocks=raw_blocks)

+ 46 - 1
frontend/src/__tests__/components/SpoolInfoCard.test.tsx

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
-import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import type { MatchedSpool, UnknownTagData } from '../../hooks/useSpoolBuddyState';
 
 const mockUpdateSpoolWeight = vi.fn();
 
@@ -162,4 +162,49 @@ describe('UnknownTagCard', () => {
     fireEvent.click(btn);
     expect(onAdd).toHaveBeenCalledTimes(1);
   });
+
+  describe('with data decoded from a Bambu tag (#2685)', () => {
+    const tagData: UnknownTagData = {
+    material: 'PETG',
+    subtype: 'Translucent',
+    color_name: 'Translucent Pink',
+    rgba: 'F9C1BD80',
+    extra_colors: null,
+    effect_type: null,
+    brand: 'Bambu Lab',
+    label_weight: 1000,
+    slicer_filament: 'GFG01',
+    slicer_filament_name: 'Bambu PETG Translucent',
+    nozzle_temp_min: 230,
+    nozzle_temp_max: 260,
+    core_weight: 250,
+    core_weight_catalog_id: null,
+    variant_id: null,
+    diameter: 1.75,
+    drying_temp: null,
+    drying_time: null,
+  };
+
+    it('shows the spool the tag describes, still marked as a new tag', () => {
+      render(<UnknownTagCard tagUid="3CBDBD05" scaleWeight={null} tagData={tagData} />);
+
+      expect(screen.getByText('Bambu Lab PETG Translucent')).toBeInTheDocument();
+      expect(screen.getByText('Translucent Pink')).toBeInTheDocument();
+      expect(screen.getByText(/New Tag Detected/)).toBeInTheDocument();
+      expect(screen.getByText('3CBDBD05')).toBeInTheDocument();
+    });
+
+    it("estimates the filament with the tag's empty-spool weight", () => {
+      render(<UnknownTagCard tagUid="3CBDBD05" scaleWeight={1200} tagData={{ ...tagData, core_weight: 216 }} />);
+
+      expect(screen.getByText(/~984g filament/)).toBeInTheDocument();
+    });
+
+    it('keeps the plain new-tag card without tag data', () => {
+      render(<UnknownTagCard tagUid="3CBDBD05" scaleWeight={null} tagData={null} />);
+
+      expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+      expect(screen.queryByText('Bambu Lab PETG Translucent')).toBeNull();
+    });
+  });
 });

+ 44 - 0
frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts

@@ -160,6 +160,50 @@ describe('useSpoolBuddyState', () => {
     expect(result.current.unknownTrayUuid).toBe('DEADBEEFDEADBEEFDEADBEEFDEADBEEF');
   });
 
+  it('UNKNOWN_TAG keeps the data decoded from a Bambu tag until the tag is lifted (#2685)', () => {
+    const tagData = {
+      material: 'PLA',
+      subtype: 'Basic',
+      color_name: 'Cyan',
+      rgba: '0086D6FF',
+      extra_colors: null,
+      effect_type: null,
+      brand: 'Bambu Lab',
+      label_weight: 1000,
+      slicer_filament: 'GFA00',
+      slicer_filament_name: 'Bambu PLA Basic',
+      nozzle_temp_min: 190,
+      nozzle_temp_max: 230,
+      core_weight: 250,
+      core_weight_catalog_id: 7,
+      variant_id: 'A00-B08',
+      diameter: 1.75,
+      drying_temp: 55,
+      drying_time: 8,
+    };
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-unknown-tag', { tag_uid: '865947E2', device_id: 'dev-1', tag_data: tagData });
+    });
+    expect(result.current.unknownTagData).toEqual(tagData);
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-removed', { tag_uid: '865947E2', device_id: 'dev-1' });
+    });
+    expect(result.current.unknownTagData).toBeNull();
+  });
+
+  it('UNKNOWN_TAG without tag_data leaves unknownTagData empty (#2685)', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-unknown-tag', { tag_uid: 'AABB1122334455FF', device_id: 'dev-1' });
+    });
+    expect(result.current.unknownTagUid).toBe('AABB1122334455FF');
+    expect(result.current.unknownTagData).toBeNull();
+  });
+
   it('UNKNOWN_TAG without tray_uuid leaves unknownTrayUuid null', () => {
     const { result } = renderHook(() => useSpoolBuddyState());
 

+ 222 - 0
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
 import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
 import { ToastProvider } from '../../contexts/ToastContext';
+import type { UnknownTagData } from '../../hooks/useSpoolBuddyState';
 
 // The kiosk runs signed in; these tests render it without the auth provider.
 vi.mock('../../contexts/AuthContext', async (importOriginal) => {
@@ -97,6 +98,7 @@ const mockOutletContext = {
     matchedSpool: null,
     unknownTagUid: null,
     unknownTrayUuid: null,
+    unknownTagData: null as UnknownTagData | null,
     deviceOnline: true,
     deviceId: 'dev-1',
     remainingWeight: null,
@@ -852,6 +854,226 @@ describe('SpoolBuddyDashboard', () => {
     });
   });
 
+  describe('Quick Add from a decoded Bambu tag (#2685)', () => {
+    const TRAY_UUID = '36B6C12DB3DA4A39A50C275F510BD399';
+    const TAG_DATA: UnknownTagData = {
+      material: 'PLA',
+      subtype: 'Basic',
+      color_name: 'Cyan',
+      rgba: '0086D6FF',
+      extra_colors: null,
+      effect_type: null,
+      brand: 'Bambu Lab',
+      label_weight: 1000,
+      slicer_filament: 'GFA00',
+      slicer_filament_name: 'Bambu PLA Basic',
+      nozzle_temp_min: 190,
+      nozzle_temp_max: 230,
+      core_weight: 216,
+      core_weight_catalog_id: 7,
+      variant_id: 'A00-B08',
+      diameter: 1.75,
+      drying_temp: 55,
+      drying_time: 8,
+    };
+
+    async function setSpoolmanMode(enabled: boolean) {
+      const { api } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: enabled ? 'true' : 'false',
+        spoolman_url: enabled ? 'http://localhost:7912' : '',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+    }
+
+    it('shows the spool the tag describes instead of the basic-PLA warning', async () => {
+      await setSpoolmanMode(false);
+      renderPage({ unknownTagUid: '865947E2', unknownTrayUuid: TRAY_UUID, unknownTagData: TAG_DATA });
+
+      // The card names the spool before the dialog is opened
+      await waitFor(() => expect(screen.getByText('Bambu Lab PLA Basic')).toBeDefined());
+
+      fireEvent.click(screen.getAllByText('Add to Inventory')[0]);
+
+      // ...and the dialog repeats it with the weight and diameter it will save
+      await waitFor(() => expect(screen.getByText('Cyan · 1000 g · 1.75 mm')).toBeDefined());
+      expect(screen.getAllByText('Bambu Lab PLA Basic')).toHaveLength(2);
+      expect(screen.queryByText('Add Anyway')).toBeNull();
+      expect(screen.queryByText(/basic PLA spool entry/)).toBeNull();
+    });
+
+    it('takes the tag data when the same tag comes back with a good read', async () => {
+      // First read failed (no blocks, so no tag data), the spool is put back and
+      // read again: Quick Add must create the decoded spool, not the blank PLA one.
+      const { api } = await import('../../api/client');
+      await setSpoolmanMode(false);
+      const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
+      function DynWrapper() {
+        const [sbState, setSbState] = React.useState({
+          ...mockOutletContext.sbState,
+          unknownTagUid: '865947E2',
+          unknownTrayUuid: null as string | null,
+          unknownTagData: null as UnknownTagData | null,
+        });
+        setterRef.current = setSbState;
+        return <Outlet context={{ ...mockOutletContext, sbState }} />;
+      }
+      const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+      render(
+        <ToastProvider>
+          <QueryClientProvider client={qc}>
+            <MemoryRouter initialEntries={['/spoolbuddy']}>
+              <Routes>
+                <Route element={<DynWrapper />}>
+                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+                </Route>
+              </Routes>
+            </MemoryRouter>
+          </QueryClientProvider>
+        </ToastProvider>
+      );
+
+      // Failed read: the plain new-tag card
+      await waitFor(() => expect(screen.getByText('865947E2')).toBeDefined());
+      expect(screen.queryByText('Bambu Lab PLA Basic')).toBeNull();
+
+      // Lifted off the reader, then placed again and read properly
+      act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: null, unknownTrayUuid: null, unknownTagData: null })));
+      act(() =>
+        setterRef.current!((prev) => ({ ...prev, unknownTagUid: '865947E2', unknownTrayUuid: TRAY_UUID, unknownTagData: TAG_DATA })),
+      );
+
+      await waitFor(() => expect(screen.getByText('Bambu Lab PLA Basic')).toBeDefined());
+      fireEvent.click(screen.getAllByText('Add to Inventory')[0]);
+      fireEvent.click(await waitFor(() => screen.getByText('Add')));
+
+      await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
+      expect((api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
+        material: 'PLA',
+        subtype: 'Basic',
+        color_name: 'Cyan',
+        brand: 'Bambu Lab',
+        core_weight: 216,
+        tag_uid: '865947E2',
+        tray_uuid: TRAY_UUID,
+        tag_type: 'bambulab',
+      });
+    });
+
+    it('keeps the tag data when the same tag comes back with a failed read', async () => {
+      // Good read first, the spool is lifted and put back, and that read fails
+      // (no blocks): the card and Quick Add must keep the first read's spool.
+      const { api } = await import('../../api/client');
+      await setSpoolmanMode(false);
+      const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
+      function DynWrapper() {
+        const [sbState, setSbState] = React.useState({
+          ...mockOutletContext.sbState,
+          unknownTagUid: '865947E2',
+          unknownTrayUuid: TRAY_UUID as string | null,
+          unknownTagData: TAG_DATA as UnknownTagData | null,
+        });
+        setterRef.current = setSbState;
+        return <Outlet context={{ ...mockOutletContext, sbState }} />;
+      }
+      const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+      render(
+        <ToastProvider>
+          <QueryClientProvider client={qc}>
+            <MemoryRouter initialEntries={['/spoolbuddy']}>
+              <Routes>
+                <Route element={<DynWrapper />}>
+                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+                </Route>
+              </Routes>
+            </MemoryRouter>
+          </QueryClientProvider>
+        </ToastProvider>
+      );
+
+      // Good read: the card names the spool
+      await waitFor(() => expect(screen.getByText('Bambu Lab PLA Basic')).toBeDefined());
+
+      // Lifted off the reader, then placed again and the read fails
+      act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: null, unknownTrayUuid: null, unknownTagData: null })));
+      act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: '865947E2', unknownTrayUuid: null, unknownTagData: null })));
+
+      await waitFor(() => expect(screen.getByText('865947E2')).toBeDefined());
+      expect(screen.getByText('Bambu Lab PLA Basic')).toBeDefined();
+      fireEvent.click(screen.getAllByText('Add to Inventory')[0]);
+      fireEvent.click(await waitFor(() => screen.getByText('Add')));
+
+      await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
+      expect((api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
+        material: 'PLA',
+        subtype: 'Basic',
+        color_name: 'Cyan',
+        brand: 'Bambu Lab',
+        core_weight: 216,
+        tag_uid: '865947E2',
+        tray_uuid: TRAY_UUID,
+        tag_type: 'bambulab',
+      });
+    });
+
+    it('creates that spool in local mode, with its tray UUID and catalog tare', async () => {
+      const { api } = await import('../../api/client');
+      await setSpoolmanMode(false);
+      renderPage({ unknownTagUid: '865947E2', unknownTrayUuid: TRAY_UUID, unknownTagData: TAG_DATA });
+
+      fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
+      fireEvent.click(await waitFor(() => screen.getByText('Add')));
+
+      await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
+      expect((api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
+        material: 'PLA',
+        subtype: 'Basic',
+        color_name: 'Cyan',
+        rgba: '0086D6FF',
+        brand: 'Bambu Lab',
+        label_weight: 1000,
+        core_weight: 216,
+        core_weight_catalog_id: 7,
+        slicer_filament: 'GFA00',
+        slicer_filament_name: 'Bambu PLA Basic',
+        nozzle_temp_min: 190,
+        nozzle_temp_max: 230,
+        tag_uid: '865947E2',
+        tray_uuid: TRAY_UUID,
+        tag_type: 'bambulab',
+      });
+    });
+
+    it('creates that spool in Spoolman mode, still without a tare (#2908)', async () => {
+      const { api } = await import('../../api/client');
+      await setSpoolmanMode(true);
+      renderPage({ unknownTagUid: '865947E2', unknownTrayUuid: TRAY_UUID, unknownTagData: TAG_DATA });
+
+      fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
+      fireEvent.click(await waitFor(() => screen.getByText('Add')));
+
+      await waitFor(() => expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1));
+      const payload = (api.createSpoolmanInventorySpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
+      expect(payload).toMatchObject({
+        material: 'PLA',
+        subtype: 'Basic',
+        color_name: 'Cyan',
+        rgba: '0086D6FF',
+        brand: 'Bambu Lab',
+        label_weight: 1000,
+        slicer_filament: 'GFA00',
+        nozzle_temp_min: 190,
+        nozzle_temp_max: 230,
+      });
+      expect(payload).not.toHaveProperty('core_weight');
+      await waitFor(() =>
+        expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(4, { tag_uid: '865947E2', tray_uuid: TRAY_UUID }),
+      );
+    });
+  });
+
   describe('Spoolman mode, spool lifted while a dialog is open', () => {
     // The reader state is cleared when the tag leaves the reader; the card and
     // its dialogs stay. Linking must use the tag the card shows.

+ 33 - 12
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
-import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import type { MatchedSpool, UnknownTagData } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
 import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
@@ -221,14 +221,18 @@ interface UnknownTagCardProps {
   tagUid: string;
   scaleWeight: number | null;
   coreWeight?: number;
+  // What a Bambu tag's own blocks say about the roll (#2685). When present
+  // the card shows that spool, so a Bambu roll doesn't look unrecognised
+  // before Add to Inventory is opened.
+  tagData?: UnknownTagData | null;
   onLinkSpool?: () => void;
   onAddToInventory?: () => void;
   onClose?: () => void;
 }
 
-export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {
+export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, tagData, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {
   const { t } = useTranslation();
-  const defaultCoreWeight = coreWeight ?? DEFAULT_CORE_WEIGHT_G;
+  const defaultCoreWeight = coreWeight ?? tagData?.core_weight ?? DEFAULT_CORE_WEIGHT_G;
   const grossWeight = scaleWeight !== null
     ? Math.round(Math.max(0, scaleWeight))
     : null;
@@ -238,15 +242,32 @@ export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, o
 
   return (
     <div className="flex flex-col items-center text-center space-y-5">
-      <div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center">
-        <svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
-        </svg>
-      </div>
-      <div>
-        <h3 className="text-lg font-semibold text-zinc-100">{t('spoolbuddy.dashboard.newTag', 'New Tag Detected')}</h3>
-        <p className="text-sm text-zinc-500 font-mono mt-1">{tagUid}</p>
-      </div>
+      {tagData ? (
+        <>
+          <SpoolIcon color={spoolColorString(tagData.rgba)} isEmpty={false} size={80} spool={tagData} />
+          <div>
+            <h3 className="text-lg font-semibold text-zinc-100">
+              {[tagData.brand, tagData.material, tagData.subtype].filter(Boolean).join(' ')}
+            </h3>
+            {tagData.color_name && <p className="text-sm text-zinc-300 mt-0.5">{tagData.color_name}</p>}
+            <p className="text-xs text-zinc-500 mt-1">
+              {t('spoolbuddy.dashboard.newTag', 'New Tag Detected')} · <span className="font-mono">{tagUid}</span>
+            </p>
+          </div>
+        </>
+      ) : (
+        <>
+          <div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center">
+            <svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
+            </svg>
+          </div>
+          <div>
+            <h3 className="text-lg font-semibold text-zinc-100">{t('spoolbuddy.dashboard.newTag', 'New Tag Detected')}</h3>
+            <p className="text-sm text-zinc-500 font-mono mt-1">{tagUid}</p>
+          </div>
+        </>
+      )}
       {grossWeight !== null && (
         <div className="text-sm text-zinc-400">
           <span className="font-mono font-semibold">{grossWeight}g</span> {t('spoolbuddy.dashboard.onScale', 'on scale')}

+ 31 - 1
frontend/src/hooks/useSpoolBuddyState.ts

@@ -20,6 +20,30 @@ export interface MatchedSpool {
   weight_used: number;
 }
 
+// What an unknown Bambu tag's own blocks say about the roll, resolved by the
+// backend the way AMS auto-creation resolves a tray (#2685). Quick Add creates
+// the spool from it. Absent for non-Bambu tags and older daemons.
+export interface UnknownTagData {
+  material: string;
+  subtype: string | null;
+  color_name: string | null;
+  rgba: string | null;
+  extra_colors: string | null;
+  effect_type: string | null;
+  brand: string;
+  label_weight: number;
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+  nozzle_temp_min: number | null;
+  nozzle_temp_max: number | null;
+  core_weight: number;
+  core_weight_catalog_id: number | null;
+  variant_id: string | null;
+  diameter: number | null;
+  drying_temp: number | null;
+  drying_time: number | null;
+}
+
 export interface SpoolBuddyState {
   weight: number | null;
   weightStable: boolean;
@@ -27,6 +51,7 @@ export interface SpoolBuddyState {
   matchedSpool: MatchedSpool | null;
   unknownTagUid: string | null;
   unknownTrayUuid: string | null;
+  unknownTagData: UnknownTagData | null;
   deviceOnline: boolean;
   deviceId: string | null;
 }
@@ -34,7 +59,7 @@ export interface SpoolBuddyState {
 type Action =
   | { type: 'WEIGHT_UPDATE'; weight: number; stable: boolean; rawAdc: number; deviceId: string }
   | { type: 'TAG_MATCHED'; spool: MatchedSpool; deviceId: string }
-  | { type: 'UNKNOWN_TAG'; tagUid: string; trayUuid: string | null; deviceId: string }
+  | { type: 'UNKNOWN_TAG'; tagUid: string; trayUuid: string | null; tagData: UnknownTagData | null; deviceId: string }
   | { type: 'TAG_REMOVED'; deviceId: string }
   | { type: 'DEVICE_ONLINE'; deviceId: string }
   | { type: 'DEVICE_OFFLINE'; deviceId: string };
@@ -46,6 +71,7 @@ const initialState: SpoolBuddyState = {
   matchedSpool: null,
   unknownTagUid: null,
   unknownTrayUuid: null,
+  unknownTagData: null,
   deviceOnline: false,
   deviceId: null,
 };
@@ -67,6 +93,7 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
         matchedSpool: action.spool,
         unknownTagUid: null,
         unknownTrayUuid: null,
+        unknownTagData: null,
         deviceId: action.deviceId,
       };
     case 'UNKNOWN_TAG':
@@ -75,6 +102,7 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
         matchedSpool: null,
         unknownTagUid: action.tagUid,
         unknownTrayUuid: action.trayUuid ?? null,
+        unknownTagData: action.tagData,
         deviceId: action.deviceId,
       };
     case 'TAG_REMOVED':
@@ -83,6 +111,7 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
         matchedSpool: null,
         unknownTagUid: null,
         unknownTrayUuid: null,
+        unknownTagData: null,
       };
     case 'DEVICE_ONLINE':
       return {
@@ -149,6 +178,7 @@ export function useSpoolBuddyState() {
       type: 'UNKNOWN_TAG',
       tagUid: detail.tag_uid ?? '',
       trayUuid: detail.tray_uuid ?? null,
+      tagData: detail.tag_data ?? null,
       deviceId: detail.device_id ?? '',
     });
   }, []);

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -7413,6 +7413,7 @@ export default {
       assignToAms: 'AMS zuweisen',
       syncWeight: 'Gewicht sync.',
       addToInventory: 'Zum Inventar hinzufügen',
+      quickAddFromTag: 'Fügt diese Spule mit den Daten von ihrem Bambu-Tag hinzu. Du kannst sie später in Bambuddy bearbeiten.',
       assignToAmsTitle: 'AMS zuweisen',
       selectSlot: 'Slot auswählen',
       assign: 'Zuweisen',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -7466,6 +7466,7 @@ export default {
       assignToAms: 'Assign to AMS',
       syncWeight: 'Sync Weight',
       addToInventory: 'Add to Inventory',
+      quickAddFromTag: 'Adds this spool with the details read from its Bambu tag. You can edit them later in Bambuddy.',
       assignToAmsTitle: 'Assign to AMS',
       selectSlot: 'Select a slot',
       assign: 'Assign',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -7421,6 +7421,7 @@ export default {
       assignToAms: 'Asignar al AMS',
       syncWeight: 'Sincronizar peso',
       addToInventory: 'Añadir al inventario',
+      quickAddFromTag: 'Añade esta bobina con los datos leídos de su etiqueta Bambu. Puedes editarlos más tarde en Bambuddy.',
       assignToAmsTitle: 'Asignar al AMS',
       selectSlot: 'Seleccione una ranura',
       assign: 'Asignar',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -7401,6 +7401,7 @@ export default {
       assignToAms: 'Assigner à l\'AMS',
       syncWeight: 'Synchroniser le poids',
       addToInventory: 'Ajouter à l\'inventaire',
+      quickAddFromTag: 'Ajoute cette bobine avec les informations lues sur son étiquette Bambu. Vous pourrez les modifier plus tard dans Bambuddy.',
       assignToAmsTitle: 'Assigner à l\'AMS',
       selectSlot: 'Sélectionner un emplacement',
       assign: 'Assigner',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -7400,6 +7400,7 @@ export default {
       assignToAms: 'Assegna all\'AMS',
       syncWeight: 'Sincronizza peso',
       addToInventory: 'Aggiungi all\'inventario',
+      quickAddFromTag: 'Aggiunge questa bobina con i dati letti dal suo tag Bambu. Potrai modificarli in seguito in Bambuddy.',
       assignToAmsTitle: 'Assegna all\'AMS',
       selectSlot: 'Seleziona uno slot',
       assign: 'Assegna',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -7414,6 +7414,7 @@ export default {
       assignToAms: 'AMSに割り当て',
       syncWeight: '重量同期',
       addToInventory: 'インベントリに追加',
+      quickAddFromTag: 'Bambuタグから読み取った情報でこのスプールを追加します。後でBambuddyで編集できます。',
       assignToAmsTitle: 'AMSに割り当て',
       selectSlot: 'スロットを選択',
       assign: '割り当て',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -7061,6 +7061,7 @@ export default {
       assignToAms: 'AMS에 할당',
       syncWeight: '무게 동기화',
       addToInventory: '재고에 추가',
+      quickAddFromTag: 'Bambu 태그에서 읽은 정보로 이 스풀을 추가합니다. 나중에 Bambuddy에서 수정할 수 있습니다.',
       assignToAmsTitle: 'AMS에 할당',
       selectSlot: '슬롯 선택',
       assign: '할당',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -7464,6 +7464,7 @@ export default {
       assignToAms: 'Aan AMS toewijzen',
       syncWeight: 'Gewicht synchroniseren',
       addToInventory: 'Aan voorraad toevoegen',
+      quickAddFromTag: 'Voegt deze spoel toe met de gegevens van de Bambu-tag. Je kunt ze later in Bambuddy aanpassen.',
       assignToAmsTitle: 'Aan AMS toewijzen',
       selectSlot: 'Selecteer een slot',
       assign: 'Toewijzen',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -7400,6 +7400,7 @@ export default {
       assignToAms: 'Atribuir ao AMS',
       syncWeight: 'Sincronizar Peso',
       addToInventory: 'Adicionar ao Inventário',
+      quickAddFromTag: 'Adiciona este carretel com os dados lidos da etiqueta Bambu. Você pode editá-los depois no Bambuddy.',
       assignToAmsTitle: 'Atribuir ao AMS',
       selectSlot: 'Selecionar um slot',
       assign: 'Atribuir',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -7049,6 +7049,7 @@ export default {
       assignToAms: "Назначить в AMS",
       syncWeight: "Синхронизировать массу",
       addToInventory: "Добавить в учёт",
+      quickAddFromTag: 'Добавляет эту катушку с данными, считанными с её метки Bambu. Позже их можно изменить в Bambuddy.',
       assignToAmsTitle: "Назначить в AMS",
       selectSlot: "Выберите слот",
       assign: "Назначить",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -7462,6 +7462,7 @@ errors: {
       assignToAms: 'Tilldela till AMS',
       syncWeight: 'Synkronisera vikt',
       addToInventory: 'Lägg till i inventarium',
+      quickAddFromTag: 'Lägger till spolen med uppgifterna från dess Bambu-tagg. Du kan ändra dem senare i Bambuddy.',
       assignToAmsTitle: 'Tilldela till AMS',
       selectSlot: 'Välj ett fack',
       assign: 'Tilldela',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -7357,6 +7357,7 @@ export default {
       assignToAms: "AMS'e Ata",
       syncWeight: 'Ağırlığı Senkronize Et',
       addToInventory: 'Envantere Ekle',
+      quickAddFromTag: 'Bu makarayı Bambu etiketinden okunan bilgilerle ekler. Bunları daha sonra Bambuddy içinde düzenleyebilirsiniz.',
       assignToAmsTitle: "AMS'e Ata",
       selectSlot: 'Bir yuva seçin',
       assign: 'Ata',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -7454,6 +7454,7 @@ export default {
       assignToAms: "Призначити AMS",
       syncWeight: "Синхронізувати вагу",
       addToInventory: "Додати в інвентар",
+      quickAddFromTag: 'Додає цю котушку з даними, зчитаними з її мітки Bambu. Пізніше їх можна змінити в Bambuddy.',
       assignToAmsTitle: "Призначити AMS",
       selectSlot: "Вибрати слот",
       assign: "Призначити",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -7398,6 +7398,7 @@ export default {
       assignToAms: '分配到 AMS',
       syncWeight: '同步重量',
       addToInventory: '添加到库存',
+      quickAddFromTag: '使用从 Bambu 标签读取的信息添加此料卷。之后可以在 Bambuddy 中编辑。',
       assignToAmsTitle: '分配到 AMS',
       selectSlot: '选择槽位',
       assign: '分配',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -7398,6 +7398,7 @@ export default {
       assignToAms: '分配到 AMS',
       syncWeight: '同步重量',
       addToInventory: '新增到庫存',
+      quickAddFromTag: '使用從 Bambu 標籤讀取的資訊新增此料捲。之後可以在 Bambuddy 中編輯。',
       assignToAmsTitle: '分配到 AMS',
       selectSlot: '選擇槽位',
       assign: '分配',

+ 69 - 29
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -4,10 +4,11 @@ import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/rea
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api, ApiError, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
-import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import type { MatchedSpool, UnknownTagData } from '../../hooks/useSpoolBuddyState';
 import { useToast } from '../../contexts/ToastContext';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
 import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import { spoolColorString } from '../../utils/colors';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
 import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
 
@@ -232,6 +233,9 @@ export function SpoolBuddyDashboard() {
   // displayedTagId, so quick-add and link still save it after the spool is
   // lifted off the reader (#984).
   const [displayedTrayUuid, setDisplayedTrayUuid] = useState<string | null>(null);
+  // What the displayed unknown tag's own blocks say about the roll, kept with
+  // the card for the same reason, so Quick Add creates that spool (#2685).
+  const [displayedTagData, setDisplayedTagData] = useState<UnknownTagData | null>(null);
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
   const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
   const [showLinkModal, setShowLinkModal] = useState(false);
@@ -243,6 +247,7 @@ export function SpoolBuddyDashboard() {
   // Track current tag from state
   const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
   const currentTrayUuid = sbState.unknownTagUid ? sbState.unknownTrayUuid : null;
+  const currentTagData = sbState.unknownTagUid ? sbState.unknownTagData : null;
   const currentWeight = sbState.weight;
   const weightStable = sbState.weightStable;
 
@@ -333,9 +338,16 @@ export function SpoolBuddyDashboard() {
       if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
         setDisplayedTagId(currentTagId);
         setDisplayedTrayUuid(currentTrayUuid);
+        setDisplayedTagData(currentTagData);
         setDisplayedWeight(null);
         setHiddenTagId(null);
         setJustLinkedSpool(null);
+      } else if (displayedTagId === currentTagId) {
+        // The same tag read again can carry what an earlier read missed: a
+        // failed Bambu read sends no tag data or tray UUID. Take them when this
+        // read has them, but never drop what a good read already gave (#2685).
+        if (currentTagData) setDisplayedTagData(currentTagData);
+        if (currentTrayUuid) setDisplayedTrayUuid(currentTrayUuid);
       }
 
       // Update weight when stable and card is visible
@@ -347,12 +359,13 @@ export function SpoolBuddyDashboard() {
       if (hiddenTagId) {
         setDisplayedTagId(null);
         setDisplayedTrayUuid(null);
+        setDisplayedTagData(null);
         setHiddenTagId(null);
         setDisplayedWeight(null);
         setJustLinkedSpool(null);
       }
     }
-  }, [currentTagId, currentTrayUuid, currentWeight, weightStable, displayedTagId, hiddenTagId]);
+  }, [currentTagId, currentTrayUuid, currentTagData, currentWeight, weightStable, displayedTagId, hiddenTagId]);
 
   // Auto-sync weight once when known spool first detected
 
@@ -427,6 +440,23 @@ export function SpoolBuddyDashboard() {
     setQuickAddBusy(true);
     try {
       const weight = liveWeight ?? displayedWeight;
+      // A decoded Bambu tag gives the spool AMS sync would create; without one
+      // it stays a basic PLA entry to edit later (#2685).
+      const td = displayedTagData;
+      const fromTag = {
+        material: td?.material ?? 'PLA',
+        subtype: td?.subtype ?? null,
+        color_name: td?.color_name ?? null,
+        rgba: td?.rgba ?? null,
+        extra_colors: td?.extra_colors ?? null,
+        effect_type: td?.effect_type ?? null,
+        brand: td?.brand ?? null,
+        label_weight: td?.label_weight ?? 1000,
+        slicer_filament: td?.slicer_filament ?? null,
+        slicer_filament_name: td?.slicer_filament_name ?? null,
+        nozzle_temp_min: td?.nozzle_temp_min ?? null,
+        nozzle_temp_max: td?.nozzle_temp_max ?? null,
+      };
       if (spoolmanMode) {
         // No tare is sent. The quick-create has no input for one, so the 250
         // that used to go here was a placeholder, not a user's choice -- and
@@ -434,19 +464,8 @@ export function SpoolBuddyDashboard() {
         // which would stamp every spool the kiosk creates and stop it
         // inheriting the filament type's.
         const created = await api.createSpoolmanInventorySpool({
-          material: 'PLA',
-          subtype: null,
-          color_name: null,
-          rgba: null,
-          extra_colors: null,
-          effect_type: null,
-          brand: null,
-          label_weight: 1000,
+          ...fromTag,
           weight_used: 0,
-          slicer_filament: null,
-          slicer_filament_name: null,
-          nozzle_temp_min: null,
-          nozzle_temp_max: null,
           note: null,
           added_full: null,
           last_used: null,
@@ -467,21 +486,10 @@ export function SpoolBuddyDashboard() {
         });
       } else {
         await api.createSpool({
-          material: 'PLA',
-          subtype: null,
-          color_name: null,
-          rgba: null,
-          extra_colors: null,
-          effect_type: null,
-          brand: null,
-          label_weight: 1000,
-          core_weight: 250,
-          core_weight_catalog_id: null,
+          ...fromTag,
+          core_weight: td?.core_weight ?? 250,
+          core_weight_catalog_id: td?.core_weight_catalog_id ?? null,
           weight_used: 0,
-          slicer_filament: null,
-          slicer_filament_name: null,
-          nozzle_temp_min: null,
-          nozzle_temp_max: null,
           note: null,
           added_full: null,
           last_used: null,
@@ -687,6 +695,7 @@ export function SpoolBuddyDashboard() {
                 <UnknownTagCard
                   tagUid={displayedTagId}
                   scaleWeight={liveWeight ?? displayedWeight}
+                  tagData={displayedTagData}
                   onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
                   onAddToInventory={() => setShowQuickAddModal(true)}
                   onClose={handleCloseSpoolCard}
@@ -734,6 +743,31 @@ export function SpoolBuddyDashboard() {
               {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
             </h3>
 
+            {displayedTagData ? (
+              <>
+                <p className="text-sm text-zinc-400 mb-3">
+                  {t('spoolbuddy.modal.quickAddFromTag', 'Adds this spool with the details read from its Bambu tag. You can edit them later in Bambuddy.')}
+                </p>
+                <div className="flex items-center gap-3 p-3 bg-zinc-900/60 border border-zinc-700 rounded-lg mb-1">
+                  <SpoolIcon color={spoolColorString(displayedTagData.rgba)} isEmpty={false} size={40} spool={displayedTagData} />
+                  <div className="min-w-0">
+                    <p className="text-sm font-medium text-zinc-100 truncate">
+                      {[displayedTagData.brand, displayedTagData.material, displayedTagData.subtype].filter(Boolean).join(' ')}
+                    </p>
+                    <p className="text-xs text-zinc-400 truncate">
+                      {[
+                        displayedTagData.color_name,
+                        `${displayedTagData.label_weight} g`,
+                        displayedTagData.diameter ? `${displayedTagData.diameter} mm` : null,
+                      ]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </p>
+                  </div>
+                </div>
+              </>
+            ) : (
+              <>
             {/* Hint */}
             <div className="flex gap-2.5 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg mb-4">
               <svg className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -747,6 +781,8 @@ export function SpoolBuddyDashboard() {
             <p className="text-sm text-zinc-400 mb-1">
               {t('spoolbuddy.modal.quickAddDesc', 'This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.')}
             </p>
+              </>
+            )}
             <p className="text-xs text-zinc-500 font-mono mb-5">{displayedTagId}</p>
 
             <div className="flex gap-3">
@@ -761,7 +797,11 @@ export function SpoolBuddyDashboard() {
                 disabled={quickAddBusy}
                 className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors min-h-[44px]"
               >
-                {quickAddBusy ? t('common.saving', 'Saving...') : t('spoolbuddy.modal.addAnyway', 'Add Anyway')}
+                {quickAddBusy
+                  ? t('common.saving', 'Saving...')
+                  : displayedTagData
+                    ? t('common.add', 'Add')
+                    : t('spoolbuddy.modal.addAnyway', 'Add Anyway')}
               </button>
             </div>
           </div>