Просмотр исходного кода

Add SpoolBuddy NFC tag writing with OpenTag3D format

  Write NTAG213/215/216 tags for third-party spools via the SpoolBuddy
  kiosk UI. New "Write" page with three workflows: existing spool, new
  spool creation, and tag replacement. Backend encodes 133-byte OpenTag3D
  NDEF payloads (material, color, brand, weight, temp). Daemon writes
  page-by-page via PN5180 NTAG WRITE command with read-back verification.
  Write commands flow through heartbeat polling with WebSocket status
  updates. Includes 39 new tests and translations for all 6 languages.
maziggy 2 месяцев назад
Родитель
Сommit
f943420ea2

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 
 
 ### New Features
 ### New Features
+- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
 - **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
 - **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
 - **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
 - **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.

+ 130 - 1
backend/app/api/routes/spoolbuddy.py

@@ -26,6 +26,8 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagRemovedRequest,
     TagScannedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
     UpdateSpoolWeightRequest,
+    WriteTagRequest,
+    WriteTagResultRequest,
 )
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
 
 
@@ -171,7 +173,18 @@ async def device_heartbeat(
 
 
     # Return and clear pending command
     # Return and clear pending command
     pending = device.pending_command
     pending = device.pending_command
-    device.pending_command = None
+    pending_write = None
+    if pending == "write_tag" and device.pending_write_payload:
+        # Parse the stored JSON payload to include in response
+        import json
+
+        try:
+            pending_write = json.loads(device.pending_write_payload)
+        except (json.JSONDecodeError, TypeError):
+            pending_write = None
+        # Don't clear write_tag command — it gets cleared by write-result
+    else:
+        device.pending_command = None
 
 
     await db.commit()
     await db.commit()
 
 
@@ -186,6 +199,7 @@ async def device_heartbeat(
 
 
     return HeartbeatResponse(
     return HeartbeatResponse(
         pending_command=pending,
         pending_command=pending,
+        pending_write_payload=pending_write,
         tare_offset=device.tare_offset,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
         calibration_factor=device.calibration_factor,
         display_brightness=device.display_brightness,
         display_brightness=device.display_brightness,
@@ -256,6 +270,121 @@ async def nfc_tag_removed(
     return {"status": "ok"}
     return {"status": "ok"}
 
 
 
 
+@router.post("/nfc/write-tag")
+async def nfc_write_tag(
+    req: WriteTagRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Queue an NFC tag write command for a SpoolBuddy device."""
+    import json
+
+    from backend.app.models.spool import Spool
+    from backend.app.services.opentag3d import encode_opentag3d
+
+    # Find the spool
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(status_code=404, detail="Spool not found")
+
+    # Find the device
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    # Encode OpenTag3D NDEF data
+    ndef_data = encode_opentag3d(spool)
+
+    # Store write payload and set pending command
+    device.pending_write_payload = json.dumps(
+        {
+            "spool_id": spool.id,
+            "ndef_data_hex": ndef_data.hex(),
+        }
+    )
+    device.pending_command = "write_tag"
+    await db.commit()
+
+    logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
+    return {"status": "queued"}
+
+
+@router.post("/nfc/write-result")
+async def nfc_write_result(
+    req: WriteTagResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Handle NFC tag write result from SpoolBuddy daemon."""
+    # Find the device and clear pending state
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.pending_command = None
+    device.pending_write_payload = None
+
+    if req.success:
+        # Link the tag to the spool
+        from backend.app.models.spool import Spool
+
+        result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+        spool = result.scalar_one_or_none()
+        if spool:
+            spool.tag_uid = req.tag_uid.upper()
+            spool.tag_type = "ntag"
+            spool.data_origin = "opentag3d"
+            spool.encode_time = datetime.now(timezone.utc)
+            logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
+
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_written",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "tag_uid": req.tag_uid,
+            }
+        )
+    else:
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_write_failed",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "message": req.message,
+            }
+        )
+        logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
+
+    return {"status": "ok"}
+
+
+@router.post("/devices/{device_id}/cancel-write")
+async def cancel_write(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Cancel a pending write-tag command."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if device.pending_command == "write_tag":
+        device.pending_command = None
+        device.pending_write_payload = None
+        await db.commit()
+        logger.info("Write tag cancelled for device %s", device_id)
+
+    return {"status": "ok"}
+
+
 # --- Scale endpoints ---
 # --- Scale endpoints ---
 
 
 
 

+ 6 - 0
backend/app/core/database.py

@@ -1354,6 +1354,12 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add NFC tag write payload column to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 2 - 1
backend/app/models/spoolbuddy_device.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -28,6 +28,7 @@ class SpoolBuddyDevice(Base):
     last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)
     last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     pending_command: Mapped[str | None] = mapped_column(String(50))
     pending_command: Mapped[str | None] = mapped_column(String(50))
+    pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)

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

@@ -60,6 +60,7 @@ class HeartbeatRequest(BaseModel):
 
 
 class HeartbeatResponse(BaseModel):
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
     pending_command: str | None = None
+    pending_write_payload: dict | None = None
     tare_offset: int
     tare_offset: int
     calibration_factor: float
     calibration_factor: float
     display_brightness: int = 100
     display_brightness: int = 100
@@ -123,6 +124,19 @@ class CalibrationResponse(BaseModel):
 # --- Display schemas ---
 # --- Display schemas ---
 
 
 
 
+class WriteTagRequest(BaseModel):
+    device_id: str
+    spool_id: int
+
+
+class WriteTagResultRequest(BaseModel):
+    device_id: str
+    spool_id: int
+    tag_uid: str
+    success: bool
+    message: str | None = None
+
+
 class DisplaySettingsRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
     brightness: int = Field(ge=0, le=100)
     brightness: int = Field(ge=0, le=100)
     blank_timeout: int = Field(ge=0)
     blank_timeout: int = Field(ge=0)

+ 103 - 0
backend/app/services/opentag3d.py

@@ -0,0 +1,103 @@
+"""OpenTag3D NDEF encoder for NTAG tags.
+
+Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
+starting at page 4 (after the manufacturer pages).
+
+NDEF structure:
+  [CC: E1 10 12 00]              - Capability Container (4 bytes, page 4)
+  [TLV: 03 len]                  - NDEF Message TLV (2 bytes)
+  [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
+  [Type: "application/opentag3d"] - 21 bytes
+  [Payload: OpenTag3D fields]    - 102 bytes
+  [Terminator: FE]               - 1 byte
+"""
+
+import struct
+
+from backend.app.models.spool import Spool
+
+OPENTAG3D_MIME_TYPE = b"application/opentag3d"
+PAYLOAD_SIZE = 102
+TAG_VERSION = 1000  # v1.000
+
+
+def _build_payload(spool: Spool) -> bytes:
+    """Build 102-byte OpenTag3D core payload from spool fields."""
+    buf = bytearray(PAYLOAD_SIZE)
+
+    # 0x00: Tag Version (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x00, TAG_VERSION)
+
+    # 0x02: Base Material (5 bytes, UTF-8, space-padded)
+    material = (spool.material or "")[:5].ljust(5)
+    buf[0x02:0x07] = material.encode("utf-8")[:5]
+
+    # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
+    modifiers = (spool.subtype or "")[:5].ljust(5)
+    buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
+
+    # 0x0C: Reserved (15 bytes, zero-fill) — already zero
+
+    # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
+    brand = (spool.brand or "")[:16].ljust(16)
+    buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
+
+    # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
+    color_name = (spool.color_name or "")[:32].ljust(32)
+    buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
+
+    # 0x4B: Color 1 RGBA (4 bytes)
+    rgba_hex = spool.rgba or "00000000"
+    try:
+        rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
+    except ValueError:
+        rgba_bytes = b"\x00\x00\x00\x00"
+    buf[0x4B:0x4F] = rgba_bytes[:4]
+
+    # 0x4F: Colors 2-4 (12 bytes, zero-fill) — already zero
+
+    # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
+    struct.pack_into(">H", buf, 0x5C, 1750)
+
+    # 0x5E: Target Weight (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0)
+
+    # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5
+    buf[0x60] = (spool.nozzle_temp_min or 0) // 5
+
+    # 0x61: Bed Temp (1 byte) — not tracked
+    # 0x62: Density (2 bytes) — not tracked
+    # 0x64: Transmission Distance (2 bytes) — not tracked
+    # All zero — already zero
+
+    return bytes(buf)
+
+
+def encode_opentag3d(spool: Spool) -> bytes:
+    """Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator).
+
+    Returns raw bytes ready to write to NTAG starting at page 4.
+    """
+    payload = _build_payload(spool)
+    mime_type = OPENTAG3D_MIME_TYPE
+
+    # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
+    # Type length = 21
+    # Payload length = 102 (fits in SR single byte)
+    record_header = bytes([0xD2, len(mime_type), len(payload)])
+    ndef_record = record_header + mime_type + payload
+
+    # TLV: type=0x03 (NDEF Message), length
+    ndef_len = len(ndef_record)
+    if ndef_len < 0xFF:
+        tlv = bytes([0x03, ndef_len])
+    else:
+        tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
+
+    # Capability Container (page 4)
+    cc = bytes([0xE1, 0x10, 0x12, 0x00])
+
+    # Terminator TLV
+    terminator = bytes([0xFE])
+
+    return cc + tlv + ndef_record + terminator

+ 252 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -308,6 +308,258 @@ class TestNfcEndpoints:
         assert msg["tag_uid"] == "AABB1122"
         assert msg["tag_uid"] == "AABB1122"
 
 
 
 
+# ============================================================================
+# NFC write-tag endpoints
+# ============================================================================
+
+
+class TestWriteTagEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-wt")
+        spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "queued"
+
+        # Verify heartbeat returns write_tag command with payload
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        hb_data = hb.json()
+        assert hb_data["pending_command"] == "write_tag"
+        assert hb_data["pending_write_payload"] is not None
+        assert hb_data["pending_write_payload"]["spool_id"] == spool.id
+        assert "ndef_data_hex" in hb_data["pending_write_payload"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
+        """write_tag command persists across heartbeats until write-result clears it."""
+        device = await device_factory(device_id="sb-wt-persist")
+        spool = await spool_factory(material="PETG")
+
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        # First heartbeat — command present
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb1 = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+        assert hb1.json()["pending_command"] == "write_tag"
+
+        # Second heartbeat — should still be present (not cleared like tare)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb2 = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+        assert hb2.json()["pending_command"] == "write_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-wt-nospool")
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": 99999},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": "nonexistent", "spool_id": spool.id},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-wr", pending_command="write_tag")
+        spool = await spool_factory(material="PLA", tag_uid=None)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "04AABB11223344",
+                    "success": True,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_written"
+        assert msg["spool_id"] == spool.id
+        assert msg["tag_uid"] == "04AABB11223344"
+
+        # Verify spool got tag linked
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        spool_data = spool_resp.json()
+        assert spool_data["tag_uid"] == "04AABB11223344"
+        assert spool_data["tag_type"] == "ntag"
+        assert spool_data["data_origin"] == "opentag3d"
+        assert spool_data["encode_time"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_failure_broadcasts_error(
+        self, async_client: AsyncClient, device_factory, spool_factory
+    ):
+        device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
+        spool = await spool_factory(material="PLA", tag_uid=None)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "04AABB",
+                    "success": False,
+                    "message": "Write or verification failed",
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_write_failed"
+        assert msg["message"] == "Write or verification failed"
+
+        # Verify spool NOT linked
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        assert spool_resp.json()["tag_uid"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(
+            device_id="sb-wr-clear",
+            pending_command="write_tag",
+            pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
+        )
+        spool = await spool_factory()
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(
+                f"{API}/nfc/write-result",
+                json={
+                    "device_id": device.device_id,
+                    "spool_id": spool.id,
+                    "tag_uid": "AABB",
+                    "success": True,
+                },
+            )
+
+        # Heartbeat should have no pending command
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
+            )
+        assert hb.json()["pending_command"] is None
+        assert hb.json()["pending_write_payload"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
+        device = await device_factory(device_id="sb-cancel")
+        spool = await spool_factory()
+
+        # Queue a write
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        # Cancel it
+        resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
+        assert resp.status_code == 200
+
+        # Heartbeat should have no pending command
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+        assert hb.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
+        """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
+        device = await device_factory(device_id="sb-wt-ndef")
+        spool = await spool_factory(
+            material="PLA",
+            brand="Polymaker",
+            color_name="White",
+            rgba="FFFFFFFF",
+            label_weight=1000,
+        )
+
+        await async_client.post(
+            f"{API}/nfc/write-tag",
+            json={"device_id": device.device_id, "spool_id": spool.id},
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/{device.device_id}/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        payload = hb.json()["pending_write_payload"]
+        ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
+
+        # CC bytes
+        assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        # TLV type
+        assert ndef_bytes[4] == 0x03
+        # NDEF record: TNF=MIME, type=application/opentag3d
+        assert ndef_bytes[6] == 0xD2
+        assert ndef_bytes[9:30] == b"application/opentag3d"
+        # Terminator
+        assert ndef_bytes[-1] == 0xFE
+        # Total size fits NTAG213
+        assert len(ndef_bytes) <= 144
+
+
 # ============================================================================
 # ============================================================================
 # Scale endpoints
 # Scale endpoints
 # ============================================================================
 # ============================================================================

+ 142 - 0
backend/tests/unit/test_opentag3d.py

@@ -0,0 +1,142 @@
+"""Unit tests for OpenTag3D NDEF encoder."""
+
+import struct
+from unittest.mock import MagicMock
+
+from backend.app.services.opentag3d import (
+    OPENTAG3D_MIME_TYPE,
+    PAYLOAD_SIZE,
+    _build_payload,
+    encode_opentag3d,
+)
+
+
+def _make_spool(**kwargs):
+    """Create a mock Spool with default values."""
+    defaults = {
+        "material": "PLA",
+        "subtype": "Matte",
+        "brand": "Polymaker",
+        "color_name": "Jade White",
+        "rgba": "00AE42FF",
+        "label_weight": 1000,
+        "nozzle_temp_min": 220,
+    }
+    defaults.update(kwargs)
+    spool = MagicMock()
+    for k, v in defaults.items():
+        setattr(spool, k, v)
+    return spool
+
+
+class TestBuildPayload:
+    def test_payload_is_102_bytes(self):
+        spool = _make_spool()
+        payload = _build_payload(spool)
+        assert len(payload) == PAYLOAD_SIZE
+
+    def test_tag_version(self):
+        payload = _build_payload(_make_spool())
+        version = struct.unpack_from(">H", payload, 0x00)[0]
+        assert version == 1000
+
+    def test_material_field(self):
+        payload = _build_payload(_make_spool(material="PETG"))
+        material = payload[0x02:0x07].decode("utf-8")
+        assert material == "PETG "
+
+    def test_material_truncated(self):
+        payload = _build_payload(_make_spool(material="SUPERLONG"))
+        material = payload[0x02:0x07].decode("utf-8")
+        assert material == "SUPER"
+
+    def test_modifiers_field(self):
+        payload = _build_payload(_make_spool(subtype="Silk"))
+        mods = payload[0x07:0x0C].decode("utf-8")
+        assert mods == "Silk "
+
+    def test_modifiers_none(self):
+        payload = _build_payload(_make_spool(subtype=None))
+        mods = payload[0x07:0x0C].decode("utf-8")
+        assert mods == "     "
+
+    def test_reserved_is_zero(self):
+        payload = _build_payload(_make_spool())
+        assert payload[0x0C:0x1B] == b"\x00" * 15
+
+    def test_brand_field(self):
+        payload = _build_payload(_make_spool(brand="Polymaker"))
+        brand = payload[0x1B:0x2B].decode("utf-8")
+        assert brand == "Polymaker       "
+
+    def test_color_name_field(self):
+        payload = _build_payload(_make_spool(color_name="Jade White"))
+        cn = payload[0x2B:0x4B].decode("utf-8")
+        assert cn.startswith("Jade White")
+        assert len(cn) == 32
+
+    def test_rgba_field(self):
+        payload = _build_payload(_make_spool(rgba="FF0000FF"))
+        assert payload[0x4B:0x4F] == bytes([0xFF, 0x00, 0x00, 0xFF])
+
+    def test_rgba_none(self):
+        payload = _build_payload(_make_spool(rgba=None))
+        assert payload[0x4B:0x4F] == b"\x00\x00\x00\x00"
+
+    def test_target_diameter(self):
+        payload = _build_payload(_make_spool())
+        diameter = struct.unpack_from(">H", payload, 0x5C)[0]
+        assert diameter == 1750
+
+    def test_target_weight(self):
+        payload = _build_payload(_make_spool(label_weight=750))
+        weight = struct.unpack_from(">H", payload, 0x5E)[0]
+        assert weight == 750
+
+    def test_print_temp(self):
+        payload = _build_payload(_make_spool(nozzle_temp_min=220))
+        assert payload[0x60] == 44  # 220 / 5
+
+    def test_print_temp_none(self):
+        payload = _build_payload(_make_spool(nozzle_temp_min=None))
+        assert payload[0x60] == 0
+
+
+class TestEncodeOpentag3d:
+    def test_starts_with_cc(self):
+        data = encode_opentag3d(_make_spool())
+        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+
+    def test_tlv_header(self):
+        data = encode_opentag3d(_make_spool())
+        # TLV type = 0x03
+        assert data[4] == 0x03
+        # TLV length = 3 (record header) + 21 (mime type) + 102 (payload) = 126
+        assert data[5] == 126
+
+    def test_ndef_record_header(self):
+        data = encode_opentag3d(_make_spool())
+        # Record starts after CC(4) + TLV(2) = offset 6
+        assert data[6] == 0xD2  # MB|ME|SR + TNF=MIME
+        assert data[7] == len(OPENTAG3D_MIME_TYPE)  # type length = 21
+        assert data[8] == PAYLOAD_SIZE  # payload length = 102
+
+    def test_mime_type(self):
+        data = encode_opentag3d(_make_spool())
+        mime = data[9:30]
+        assert mime == b"application/opentag3d"
+
+    def test_ends_with_terminator(self):
+        data = encode_opentag3d(_make_spool())
+        assert data[-1] == 0xFE
+
+    def test_total_size(self):
+        data = encode_opentag3d(_make_spool())
+        # CC(4) + TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 133
+        assert len(data) == 133
+
+    def test_fits_ntag213(self):
+        """NTAG213 has 36 writable pages (144 bytes). Our data must fit."""
+        data = encode_opentag3d(_make_spool())
+        ntag213_capacity = 36 * 4  # 144 bytes
+        assert len(data) <= ntag213_capacity

+ 2 - 0
frontend/src/App.tsx

@@ -28,6 +28,7 @@ import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
 import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
+import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
 const queryClient = new QueryClient({
 const queryClient = new QueryClient({
   defaultOptions: {
   defaultOptions: {
     queries: {
     queries: {
@@ -122,6 +123,7 @@ function App() {
                 <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
                 <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
+                  <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
                   <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                   <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                 </Route>
                 </Route>

+ 137 - 0
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -0,0 +1,137 @@
+/**
+ * Tests for SpoolBuddyWriteTagPage:
+ * - Renders three workflow tabs
+ * - Tab switching works
+ * - Search input renders on existing/replace tabs
+ * - New spool form renders on new tab
+ * - NFC status panel shows correct idle state
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
+
+// Mock the API modules
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 1, material: 'PLA' }),
+  },
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([]),
+    writeTag: vi.fn().mockResolvedValue({ status: 'queued' }),
+    cancelWrite: vi.fn().mockResolvedValue({ status: 'ok' }),
+  },
+}));
+
+// Mock i18n
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+const mockOutletContext = {
+  selectedPrinterId: null,
+  setSelectedPrinterId: vi.fn(),
+  sbState: {
+    weight: null,
+    weightStable: false,
+    rawAdc: null,
+    matchedSpool: null,
+    unknownTagUid: null,
+    deviceOnline: false,
+    deviceId: null,
+    remainingWeight: null,
+    netWeight: null,
+  },
+  setAlert: vi.fn(),
+  displayBrightness: 100,
+  setDisplayBrightness: vi.fn(),
+  displayBlankTimeout: 0,
+  setDisplayBlankTimeout: vi.fn(),
+};
+
+function OutletWrapper() {
+  return <Outlet context={mockOutletContext} />;
+}
+
+function renderPage() {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false, gcTime: 0 } },
+  });
+
+  return render(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>
+        <Routes>
+          <Route element={<OutletWrapper />}>
+            <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyWriteTagPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders three workflow tabs', () => {
+    renderPage();
+    expect(screen.getByText('Existing Spool')).toBeDefined();
+    expect(screen.getByText('New Spool')).toBeDefined();
+    expect(screen.getByText('Replace Tag')).toBeDefined();
+  });
+
+  it('shows search input on existing spool tab', () => {
+    renderPage();
+    expect(screen.getByPlaceholderText('Search by material, color, brand...')).toBeDefined();
+  });
+
+  it('shows no spools message when list is empty', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('No spools without tags')).toBeDefined();
+    });
+  });
+
+  it('switches to new spool form on tab click', async () => {
+    renderPage();
+    fireEvent.click(screen.getByText('New Spool'));
+    await waitFor(() => {
+      expect(screen.getByText('Material')).toBeDefined();
+      expect(screen.getByText('Color Name')).toBeDefined();
+      expect(screen.getByText('Brand')).toBeDefined();
+      expect(screen.getByText('Weight (g)')).toBeDefined();
+      expect(screen.getByText('Create Spool')).toBeDefined();
+    });
+  });
+
+  it('switches to replace tab and shows appropriate empty message', async () => {
+    renderPage();
+    fireEvent.click(screen.getByText('Replace Tag'));
+    await waitFor(() => {
+      expect(screen.getByText('No spools with tags')).toBeDefined();
+    });
+  });
+
+  it('shows device offline message in NFC panel', () => {
+    renderPage();
+    expect(screen.getByText('SpoolBuddy is offline')).toBeDefined();
+  });
+
+  it('shows idle prompt when device is online but no spool selected', () => {
+    mockOutletContext.sbState.deviceOnline = true;
+    renderPage();
+    expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();
+    mockOutletContext.sbState.deviceOnline = false; // reset
+  });
+});

+ 12 - 0
frontend/src/api/client.ts

@@ -4920,4 +4920,16 @@ export const spoolbuddyApi = {
 
 
   checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
   checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
+
+  writeTag: (deviceId: string, spoolId: number) =>
+    request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
+      method: 'POST',
+      body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
+    }),
+
+  cancelWrite: (deviceId: string) =>
+    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/cancel-write`, {
+      method: 'POST',
+      body: '{}',
+    }),
 };
 };

+ 11 - 0
frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx

@@ -22,6 +22,17 @@ const navItems = [
       </svg>
       </svg>
     ),
     ),
   },
   },
+  {
+    to: '/spoolbuddy/write-tag',
+    labelKey: 'spoolbuddy.nav.writeTag',
+    fallback: 'Write',
+    icon: (
+      <svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
+        <path strokeLinecap="round" strokeLinejoin="round" d="M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" />
+      </svg>
+    ),
+  },
   {
   {
     to: '/spoolbuddy/settings',
     to: '/spoolbuddy/settings',
     labelKey: 'spoolbuddy.nav.settings',
     labelKey: 'spoolbuddy.nav.settings',

+ 9 - 0
frontend/src/hooks/useWebSocket.ts

@@ -276,6 +276,15 @@ export function useWebSocket() {
         window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
         window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
         break;
         break;
 
 
+      case 'spoolbuddy_tag_written':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
+        debouncedInvalidate('inventory-spools');
+        break;
+
+      case 'spoolbuddy_tag_write_failed':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
+        break;
+
       case 'spoolbuddy_online':
       case 'spoolbuddy_online':
         window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
         window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
         break;
         break;

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

@@ -3579,6 +3579,7 @@ export default {
       dashboard: 'Dashboard',
       dashboard: 'Dashboard',
       ams: 'AMS',
       ams: 'AMS',
       inventory: 'Inventar',
       inventory: 'Inventar',
+      writeTag: 'Schreiben',
       settings: 'Einstellungen',
       settings: 'Einstellungen',
     },
     },
     status: {
     status: {
@@ -3724,5 +3725,36 @@ export default {
       upToDate: 'Aktuell',
       upToDate: 'Aktuell',
       includeBeta: 'Beta-Versionen einschließen',
       includeBeta: 'Beta-Versionen einschließen',
     },
     },
+    writeTag: {
+      tabExisting: 'Vorhandene Spule',
+      tabNew: 'Neue Spule',
+      tabReplace: 'Tag ersetzen',
+      searchPlaceholder: 'Suche nach Material, Farbe, Marke...',
+      noUntaggedSpools: 'Keine Spulen ohne Tags',
+      noTaggedSpools: 'Keine Spulen mit Tags',
+      selectSpool: 'Spule auswählen, dann einen NTAG auf den Leser legen',
+      placeTag: 'NTAG auf den Leser legen',
+      tagReady: 'Tag erkannt — bereit zum Schreiben',
+      writeTag: 'Tag beschreiben',
+      replaceTag: 'Tag ersetzen',
+      writing: 'Tag wird beschrieben...',
+      waiting: 'Warte auf SpoolBuddy...',
+      writeSuccess: 'Tag erfolgreich beschrieben!',
+      writeFailed: 'Schreiben fehlgeschlagen',
+      queueFailed: 'Schreibbefehl konnte nicht eingereiht werden',
+      tryAgain: 'Erneut versuchen',
+      cancel: 'Abbrechen',
+      replaceWarning: 'Alter Tag wird getrennt. Neuer Tag ersetzt ihn.',
+      deviceOffline: 'SpoolBuddy ist offline',
+      material: 'Material',
+      colorName: 'Farbname',
+      color: 'Farbe',
+      brand: 'Marke',
+      weight: 'Gewicht (g)',
+      createSpool: 'Spule erstellen',
+      creating: 'Wird erstellt...',
+      spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',
+      createFailed: 'Spule konnte nicht erstellt werden',
+    },
   },
   },
 };
 };

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

@@ -3584,6 +3584,7 @@ export default {
       dashboard: 'Dashboard',
       dashboard: 'Dashboard',
       ams: 'AMS',
       ams: 'AMS',
       inventory: 'Inventory',
       inventory: 'Inventory',
+      writeTag: 'Write',
       settings: 'Settings',
       settings: 'Settings',
     },
     },
     status: {
     status: {
@@ -3729,5 +3730,36 @@ export default {
       upToDate: 'Up to date',
       upToDate: 'Up to date',
       includeBeta: 'Include beta versions',
       includeBeta: 'Include beta versions',
     },
     },
+    writeTag: {
+      tabExisting: 'Existing Spool',
+      tabNew: 'New Spool',
+      tabReplace: 'Replace Tag',
+      searchPlaceholder: 'Search by material, color, brand...',
+      noUntaggedSpools: 'No spools without tags',
+      noTaggedSpools: 'No spools with tags',
+      selectSpool: 'Select a spool, then place a blank NTAG on the reader',
+      placeTag: 'Place an NTAG on the reader',
+      tagReady: 'Tag detected — ready to write',
+      writeTag: 'Write Tag',
+      replaceTag: 'Replace Tag',
+      writing: 'Writing tag...',
+      waiting: 'Waiting for SpoolBuddy...',
+      writeSuccess: 'Tag written successfully!',
+      writeFailed: 'Write failed',
+      queueFailed: 'Failed to queue write command',
+      tryAgain: 'Try Again',
+      cancel: 'Cancel',
+      replaceWarning: 'Old tag will be unlinked. New tag will replace it.',
+      deviceOffline: 'SpoolBuddy is offline',
+      material: 'Material',
+      colorName: 'Color Name',
+      color: 'Color',
+      brand: 'Brand',
+      weight: 'Weight (g)',
+      createSpool: 'Create Spool',
+      creating: 'Creating...',
+      spoolCreated: 'Spool created! Ready to write.',
+      createFailed: 'Failed to create spool',
+    },
   },
   },
 };
 };

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

@@ -3547,6 +3547,7 @@ export default {
       dashboard: 'Tableau de bord',
       dashboard: 'Tableau de bord',
       ams: 'AMS',
       ams: 'AMS',
       inventory: 'Inventaire',
       inventory: 'Inventaire',
+      writeTag: 'Écrire',
       settings: 'Paramètres',
       settings: 'Paramètres',
     },
     },
     status: {
     status: {
@@ -3673,5 +3674,36 @@ export default {
       upToDate: 'À jour',
       upToDate: 'À jour',
       includeBeta: 'Inclure les versions bêta',
       includeBeta: 'Inclure les versions bêta',
     },
     },
+    writeTag: {
+      tabExisting: 'Bobine existante',
+      tabNew: 'Nouvelle bobine',
+      tabReplace: 'Remplacer le tag',
+      searchPlaceholder: 'Rechercher par matériau, couleur, marque...',
+      noUntaggedSpools: 'Aucune bobine sans tag',
+      noTaggedSpools: 'Aucune bobine avec tag',
+      selectSpool: 'Sélectionnez une bobine, puis placez un NTAG sur le lecteur',
+      placeTag: 'Placez un NTAG sur le lecteur',
+      tagReady: 'Tag détecté — prêt à écrire',
+      writeTag: 'Écrire le tag',
+      replaceTag: 'Remplacer le tag',
+      writing: 'Écriture du tag...',
+      waiting: 'En attente de SpoolBuddy...',
+      writeSuccess: 'Tag écrit avec succès !',
+      writeFailed: 'Échec de l\'écriture',
+      queueFailed: 'Impossible de mettre en file la commande d\'écriture',
+      tryAgain: 'Réessayer',
+      cancel: 'Annuler',
+      replaceWarning: 'L\'ancien tag sera dissocié. Le nouveau tag le remplacera.',
+      deviceOffline: 'SpoolBuddy est hors ligne',
+      material: 'Matériau',
+      colorName: 'Nom de la couleur',
+      color: 'Couleur',
+      brand: 'Marque',
+      weight: 'Poids (g)',
+      createSpool: 'Créer la bobine',
+      creating: 'Création...',
+      spoolCreated: 'Bobine créée ! Prêt à écrire.',
+      createFailed: 'Impossible de créer la bobine',
+    },
   },
   },
 };
 };

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

@@ -2935,6 +2935,7 @@ export default {
       dashboard: 'Dashboard',
       dashboard: 'Dashboard',
       ams: 'AMS',
       ams: 'AMS',
       inventory: 'Inventario',
       inventory: 'Inventario',
+      writeTag: 'Scrivi',
       settings: 'Impostazioni',
       settings: 'Impostazioni',
     },
     },
     status: {
     status: {
@@ -3061,5 +3062,36 @@ export default {
       upToDate: 'Aggiornato',
       upToDate: 'Aggiornato',
       includeBeta: 'Includi versioni beta',
       includeBeta: 'Includi versioni beta',
     },
     },
+    writeTag: {
+      tabExisting: 'Bobina esistente',
+      tabNew: 'Nuova bobina',
+      tabReplace: 'Sostituisci tag',
+      searchPlaceholder: 'Cerca per materiale, colore, marca...',
+      noUntaggedSpools: 'Nessuna bobina senza tag',
+      noTaggedSpools: 'Nessuna bobina con tag',
+      selectSpool: 'Seleziona una bobina, poi posiziona un NTAG sul lettore',
+      placeTag: 'Posiziona un NTAG sul lettore',
+      tagReady: 'Tag rilevato — pronto per la scrittura',
+      writeTag: 'Scrivi tag',
+      replaceTag: 'Sostituisci tag',
+      writing: 'Scrittura tag...',
+      waiting: 'In attesa di SpoolBuddy...',
+      writeSuccess: 'Tag scritto con successo!',
+      writeFailed: 'Scrittura fallita',
+      queueFailed: 'Impossibile accodare il comando di scrittura',
+      tryAgain: 'Riprova',
+      cancel: 'Annulla',
+      replaceWarning: 'Il vecchio tag verrà scollegato. Il nuovo tag lo sostituirà.',
+      deviceOffline: 'SpoolBuddy è offline',
+      material: 'Materiale',
+      colorName: 'Nome colore',
+      color: 'Colore',
+      brand: 'Marca',
+      weight: 'Peso (g)',
+      createSpool: 'Crea bobina',
+      creating: 'Creazione...',
+      spoolCreated: 'Bobina creata! Pronto per la scrittura.',
+      createFailed: 'Impossibile creare la bobina',
+    },
   },
   },
 };
 };

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

@@ -3413,6 +3413,7 @@ export default {
       dashboard: 'ダッシュボード',
       dashboard: 'ダッシュボード',
       ams: 'AMS',
       ams: 'AMS',
       inventory: 'インベントリ',
       inventory: 'インベントリ',
+      writeTag: '書込み',
       settings: '設定',
       settings: '設定',
     },
     },
     status: {
     status: {
@@ -3558,5 +3559,36 @@ export default {
       upToDate: '最新です',
       upToDate: '最新です',
       includeBeta: 'ベータ版を含む',
       includeBeta: 'ベータ版を含む',
     },
     },
+    writeTag: {
+      tabExisting: '既存のスプール',
+      tabNew: '新規スプール',
+      tabReplace: 'タグ交換',
+      searchPlaceholder: '素材、色、ブランドで検索...',
+      noUntaggedSpools: 'タグなしのスプールがありません',
+      noTaggedSpools: 'タグ付きのスプールがありません',
+      selectSpool: 'スプールを選択し、NTAGをリーダーに置いてください',
+      placeTag: 'NTAGをリーダーに置いてください',
+      tagReady: 'タグ検出 — 書込み準備完了',
+      writeTag: 'タグ書込み',
+      replaceTag: 'タグ交換',
+      writing: 'タグ書込み中...',
+      waiting: 'SpoolBuddyを待機中...',
+      writeSuccess: 'タグの書込みが完了しました!',
+      writeFailed: '書込み失敗',
+      queueFailed: '書込みコマンドのキューに失敗しました',
+      tryAgain: '再試行',
+      cancel: 'キャンセル',
+      replaceWarning: '古いタグのリンクが解除され、新しいタグに置き換わります。',
+      deviceOffline: 'SpoolBuddyはオフラインです',
+      material: '素材',
+      colorName: '色名',
+      color: '色',
+      brand: 'ブランド',
+      weight: '重量 (g)',
+      createSpool: 'スプール作成',
+      creating: '作成中...',
+      spoolCreated: 'スプール作成完了!書込み準備ができました。',
+      createFailed: 'スプールの作成に失敗しました',
+    },
   },
   },
 };
 };

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

@@ -3545,6 +3545,7 @@ export default {
       dashboard: 'Painel',
       dashboard: 'Painel',
       ams: 'AMS',
       ams: 'AMS',
       inventory: 'Inventário',
       inventory: 'Inventário',
+      writeTag: 'Escrever',
       settings: 'Configurações',
       settings: 'Configurações',
     },
     },
     status: {
     status: {
@@ -3671,5 +3672,36 @@ export default {
       upToDate: 'Atualizado',
       upToDate: 'Atualizado',
       includeBeta: 'Incluir versões beta',
       includeBeta: 'Incluir versões beta',
     },
     },
+    writeTag: {
+      tabExisting: 'Bobina existente',
+      tabNew: 'Nova bobina',
+      tabReplace: 'Substituir tag',
+      searchPlaceholder: 'Buscar por material, cor, marca...',
+      noUntaggedSpools: 'Nenhuma bobina sem tag',
+      noTaggedSpools: 'Nenhuma bobina com tag',
+      selectSpool: 'Selecione uma bobina e coloque um NTAG no leitor',
+      placeTag: 'Coloque um NTAG no leitor',
+      tagReady: 'Tag detectado — pronto para gravar',
+      writeTag: 'Gravar Tag',
+      replaceTag: 'Substituir Tag',
+      writing: 'Gravando tag...',
+      waiting: 'Aguardando SpoolBuddy...',
+      writeSuccess: 'Tag gravado com sucesso!',
+      writeFailed: 'Falha na gravação',
+      queueFailed: 'Falha ao enfileirar comando de gravação',
+      tryAgain: 'Tentar novamente',
+      cancel: 'Cancelar',
+      replaceWarning: 'O tag antigo será desvinculado. O novo tag o substituirá.',
+      deviceOffline: 'SpoolBuddy está offline',
+      material: 'Material',
+      colorName: 'Nome da cor',
+      color: 'Cor',
+      brand: 'Marca',
+      weight: 'Peso (g)',
+      createSpool: 'Criar bobina',
+      creating: 'Criando...',
+      spoolCreated: 'Bobina criada! Pronto para gravar.',
+      createFailed: 'Falha ao criar bobina',
+    },
   },
   },
 };
 };

+ 645 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -0,0 +1,645 @@
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useOutletContext } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
+import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
+
+type Tab = 'existing' | 'new' | 'replace';
+type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
+
+const COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
+
+export function SpoolBuddyWriteTagPage() {
+  const { t } = useTranslation();
+  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+
+  const [activeTab, setActiveTab] = useState<Tab>('existing');
+  const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);
+  const [searchQuery, setSearchQuery] = useState('');
+  const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
+  const [writeMessage, setWriteMessage] = useState('');
+  const [tagOnReader, setTagOnReader] = useState(false);
+  const [tagUid, setTagUid] = useState<string | null>(null);
+
+  // New spool form state
+  const [newMaterial, setNewMaterial] = useState('PLA');
+  const [newColorName, setNewColorName] = useState('');
+  const [newColorHex, setNewColorHex] = useState('#00AE42');
+  const [newBrand, setNewBrand] = useState('');
+  const [newWeight, setNewWeight] = useState(1000);
+  const [creating, setCreating] = useState(false);
+
+  const { data: spools = [], refetch: refetchSpools } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(false),
+    refetchInterval: 10000,
+  });
+
+  const { data: devices = [] } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 5000,
+  });
+
+  const device = devices[0];
+  const deviceOnline = sbState.deviceOnline;
+
+  // Filter spools based on tab
+  const filteredSpools = useMemo(() => {
+    let list: InventorySpool[];
+    if (activeTab === 'existing') {
+      list = spools.filter(s => !s.tag_uid && !s.archived_at);
+    } else if (activeTab === 'replace') {
+      list = spools.filter(s => s.tag_uid && !s.archived_at);
+    } else {
+      return [];
+    }
+
+    if (searchQuery) {
+      const q = searchQuery.toLowerCase();
+      list = list.filter(s =>
+        (s.material?.toLowerCase().includes(q)) ||
+        (s.color_name?.toLowerCase().includes(q)) ||
+        (s.brand?.toLowerCase().includes(q)) ||
+        (s.subtype?.toLowerCase().includes(q))
+      );
+    }
+
+    return list;
+  }, [spools, activeTab, searchQuery]);
+
+  // Listen for tag events
+  const handleUnknownTag = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    const sak = detail.sak ?? detail.data?.sak;
+    if (sak === 0x00) {
+      setTagOnReader(true);
+      setTagUid(detail.tag_uid ?? detail.data?.tag_uid ?? null);
+    }
+  }, []);
+
+  const handleTagMatched = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    // Tag is on the reader — could be used for replace flow
+    setTagOnReader(true);
+    setTagUid(detail.tag_uid ?? detail.data?.tag_uid ?? null);
+  }, []);
+
+  const handleTagRemoved = useCallback(() => {
+    setTagOnReader(false);
+    setTagUid(null);
+  }, []);
+
+  const handleTagWritten = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    if (detail.spool_id === selectedSpool?.id || detail.data?.spool_id === selectedSpool?.id) {
+      setWriteStatus('success');
+      setWriteMessage(t('spoolbuddy.writeTag.writeSuccess', 'Tag written successfully!'));
+      refetchSpools();
+      setTimeout(() => {
+        setWriteStatus('idle');
+        setSelectedSpool(null);
+        setWriteMessage('');
+      }, 5000);
+    }
+  }, [selectedSpool, t, refetchSpools]);
+
+  const handleWriteFailed = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    if (detail.spool_id === selectedSpool?.id || detail.data?.spool_id === selectedSpool?.id) {
+      setWriteStatus('error');
+      setWriteMessage(detail.message ?? detail.data?.message ?? t('spoolbuddy.writeTag.writeFailed', 'Write failed'));
+    }
+  }, [selectedSpool, t]);
+
+  useEffect(() => {
+    window.addEventListener('spoolbuddy-unknown-tag', handleUnknownTag);
+    window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);
+    window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);
+    window.addEventListener('spoolbuddy-tag-written', handleTagWritten);
+    window.addEventListener('spoolbuddy-tag-write-failed', handleWriteFailed);
+    return () => {
+      window.removeEventListener('spoolbuddy-unknown-tag', handleUnknownTag);
+      window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);
+      window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);
+      window.removeEventListener('spoolbuddy-tag-written', handleTagWritten);
+      window.removeEventListener('spoolbuddy-tag-write-failed', handleWriteFailed);
+    };
+  }, [handleUnknownTag, handleTagMatched, handleTagRemoved, handleTagWritten, handleWriteFailed]);
+
+  // Clear selection when switching tabs
+  useEffect(() => {
+    setSelectedSpool(null);
+    setWriteStatus('idle');
+    setWriteMessage('');
+    setSearchQuery('');
+  }, [activeTab]);
+
+  const handleWriteTag = async () => {
+    if (!selectedSpool || !device) return;
+    setWriteStatus('writing');
+    setWriteMessage(t('spoolbuddy.writeTag.waiting', 'Waiting for SpoolBuddy...'));
+    try {
+      await spoolbuddyApi.writeTag(device.device_id, selectedSpool.id);
+    } catch {
+      setWriteStatus('error');
+      setWriteMessage(t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'));
+    }
+  };
+
+  const handleCancelWrite = async () => {
+    if (!device) return;
+    try {
+      await spoolbuddyApi.cancelWrite(device.device_id);
+    } catch { /* ignore */ }
+    setWriteStatus('idle');
+    setWriteMessage('');
+  };
+
+  const handleCreateAndSelect = async () => {
+    setCreating(true);
+    try {
+      const rgba = newColorHex.replace('#', '') + 'FF';
+      const spool = await api.createSpool({
+        material: newMaterial,
+        subtype: null,
+        color_name: newColorName || null,
+        rgba,
+        brand: newBrand || null,
+        label_weight: newWeight,
+        core_weight: 250,
+        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: true,
+        last_used: null,
+        encode_time: null,
+        tag_uid: null,
+        tray_uuid: null,
+        data_origin: null,
+        tag_type: null,
+        cost_per_kg: null,
+        last_scale_weight: null,
+        last_weighed_at: null,
+      });
+      setSelectedSpool(spool);
+      refetchSpools();
+    } catch {
+      setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
+      setWriteStatus('error');
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
+
+  return (
+    <div className="flex flex-col h-full">
+      {/* Tab bar */}
+      <div className="flex border-b border-bambu-dark-tertiary shrink-0">
+        {([
+          { key: 'existing' as Tab, label: t('spoolbuddy.writeTag.tabExisting', 'Existing Spool') },
+          { key: 'new' as Tab, label: t('spoolbuddy.writeTag.tabNew', 'New Spool') },
+          { key: 'replace' as Tab, label: t('spoolbuddy.writeTag.tabReplace', 'Replace Tag') },
+        ]).map(tab => (
+          <button
+            key={tab.key}
+            onClick={() => setActiveTab(tab.key)}
+            className={`flex-1 py-3 text-sm font-medium transition-colors ${
+              activeTab === tab.key
+                ? 'text-bambu-green border-b-2 border-bambu-green bg-bambu-dark'
+                : 'text-zinc-400 hover:text-zinc-200 hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {tab.label}
+          </button>
+        ))}
+      </div>
+
+      {/* Main content: two columns */}
+      <div className="flex flex-1 overflow-hidden">
+        {/* Left panel — spool list or form */}
+        <div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
+          {activeTab === 'new' ? (
+            <NewSpoolForm
+              material={newMaterial}
+              setMaterial={setNewMaterial}
+              colorName={newColorName}
+              setColorName={setNewColorName}
+              colorHex={newColorHex}
+              setColorHex={setNewColorHex}
+              brand={newBrand}
+              setBrand={setNewBrand}
+              weight={newWeight}
+              setWeight={setNewWeight}
+              creating={creating}
+              onSubmit={handleCreateAndSelect}
+              selectedSpool={selectedSpool}
+              t={t}
+            />
+          ) : (
+            <>
+              {/* Search */}
+              <div className="p-3 shrink-0">
+                <input
+                  type="text"
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                  placeholder={t('spoolbuddy.writeTag.searchPlaceholder', 'Search by material, color, brand...')}
+                  className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+                />
+              </div>
+
+              {/* Spool list */}
+              <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2">
+                {filteredSpools.length === 0 ? (
+                  <div className="text-center text-zinc-500 py-8 text-sm">
+                    {activeTab === 'existing'
+                      ? t('spoolbuddy.writeTag.noUntaggedSpools', 'No spools without tags')
+                      : t('spoolbuddy.writeTag.noTaggedSpools', 'No spools with tags')}
+                  </div>
+                ) : (
+                  filteredSpools.map(spool => (
+                    <SpoolListItem
+                      key={spool.id}
+                      spool={spool}
+                      selected={selectedSpool?.id === spool.id}
+                      showTag={activeTab === 'replace'}
+                      onClick={() => {
+                        setSelectedSpool(spool);
+                        setWriteStatus('idle');
+                        setWriteMessage('');
+                      }}
+                    />
+                  ))
+                )}
+              </div>
+            </>
+          )}
+        </div>
+
+        {/* Right panel — NFC status + write action */}
+        <div className="w-[340px] flex flex-col items-center justify-center p-6 shrink-0">
+          <NfcStatusPanel
+            writeStatus={writeStatus}
+            writeMessage={writeMessage}
+            selectedSpool={selectedSpool}
+            tagOnReader={tagOnReader}
+            tagUid={tagUid}
+            deviceOnline={deviceOnline}
+            canWrite={!!canWrite}
+            isReplace={activeTab === 'replace'}
+            onWrite={handleWriteTag}
+            onCancel={handleCancelWrite}
+            onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
+            t={t}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// --- Spool list item ---
+function SpoolListItem({ spool, selected, showTag, onClick }: {
+  spool: InventorySpool;
+  selected: boolean;
+  showTag: boolean;
+  onClick: () => void;
+}) {
+  const color = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#666';
+  const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+  const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
+
+  return (
+    <button
+      onClick={onClick}
+      className={`w-full flex items-center gap-3 p-3 rounded-lg text-left transition-colors ${
+        selected
+          ? 'bg-bambu-green/15 border border-bambu-green/50'
+          : 'bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-transparent'
+      }`}
+    >
+      {/* Color dot */}
+      <div
+        className="w-8 h-8 rounded-full shrink-0 border border-white/10"
+        style={{ backgroundColor: color }}
+      />
+
+      {/* Info */}
+      <div className="flex-1 min-w-0">
+        <div className="flex items-center gap-2">
+          <span className="text-sm font-medium text-white truncate">
+            {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
+          </span>
+        </div>
+        <div className="flex items-center gap-2 text-xs text-zinc-400">
+          {spool.color_name && <span>{spool.color_name}</span>}
+          <span>{remaining}g / {spool.label_weight}g ({pct}%)</span>
+        </div>
+        {showTag && spool.tag_uid && (
+          <div className="text-xs text-zinc-500 mt-0.5 font-mono">{spool.tag_uid}</div>
+        )}
+      </div>
+
+      {/* Check mark when selected */}
+      {selected && (
+        <svg className="w-5 h-5 text-bambu-green shrink-0" fill="currentColor" viewBox="0 0 20 20">
+          <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+        </svg>
+      )}
+    </button>
+  );
+}
+
+// --- New spool form ---
+function NewSpoolForm({ material, setMaterial, colorName, setColorName, colorHex, setColorHex, brand, setBrand, weight, setWeight, creating, onSubmit, selectedSpool, t }: {
+  material: string;
+  setMaterial: (v: string) => void;
+  colorName: string;
+  setColorName: (v: string) => void;
+  colorHex: string;
+  setColorHex: (v: string) => void;
+  brand: string;
+  setBrand: (v: string) => void;
+  weight: number;
+  setWeight: (v: number) => void;
+  creating: boolean;
+  onSubmit: () => void;
+  selectedSpool: InventorySpool | null;
+  t: (key: string, fallback: string) => string;
+}) {
+  if (selectedSpool) {
+    return (
+      <div className="flex flex-col items-center justify-center h-full p-6 text-center">
+        <div
+          className="w-12 h-12 rounded-full mb-4 border border-white/10"
+          style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+        />
+        <p className="text-white font-medium">
+          {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+        </p>
+        {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+        <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
+        <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-4 space-y-4 overflow-y-auto">
+      {/* Material */}
+      <div>
+        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
+        <select
+          value={material}
+          onChange={(e) => setMaterial(e.target.value)}
+          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
+        >
+          {COMMON_MATERIALS.map(m => <option key={m} value={m}>{m}</option>)}
+        </select>
+      </div>
+
+      {/* Color name + picker */}
+      <div className="flex gap-3">
+        <div className="flex-1">
+          <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
+          <input
+            type="text"
+            value={colorName}
+            onChange={(e) => setColorName(e.target.value)}
+            placeholder="Jade White"
+            className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+          />
+        </div>
+        <div>
+          <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
+          <input
+            type="color"
+            value={colorHex}
+            onChange={(e) => setColorHex(e.target.value)}
+            className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
+          />
+        </div>
+      </div>
+
+      {/* Brand */}
+      <div>
+        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
+        <input
+          type="text"
+          value={brand}
+          onChange={(e) => setBrand(e.target.value)}
+          placeholder="Polymaker"
+          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+        />
+      </div>
+
+      {/* Weight */}
+      <div>
+        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
+        <input
+          type="number"
+          value={weight}
+          onChange={(e) => setWeight(parseInt(e.target.value) || 0)}
+          min={0}
+          max={10000}
+          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
+        />
+      </div>
+
+      {/* Create button */}
+      <button
+        onClick={onSubmit}
+        disabled={creating || !material}
+        className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
+      >
+        {creating
+          ? t('spoolbuddy.writeTag.creating', 'Creating...')
+          : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
+      </button>
+    </div>
+  );
+}
+
+// --- NFC status panel ---
+function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, onWrite, onCancel, onRetry, t }: {
+  writeStatus: WriteStatus;
+  writeMessage: string;
+  selectedSpool: InventorySpool | null;
+  tagOnReader: boolean;
+  tagUid: string | null;
+  deviceOnline: boolean;
+  canWrite: boolean;
+  isReplace: boolean;
+  onWrite: () => void;
+  onCancel: () => void;
+  onRetry: () => void;
+  t: (key: string, fallback: string) => string;
+}) {
+  // Success state
+  if (writeStatus === 'success') {
+    return (
+      <div className="flex flex-col items-center text-center space-y-4">
+        <div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center">
+          <svg className="w-8 h-8 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+          </svg>
+        </div>
+        <p className="text-green-400 font-medium">{writeMessage}</p>
+        {selectedSpool && (
+          <p className="text-zinc-400 text-sm">
+            {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+            {selectedSpool.color_name ? ` - ${selectedSpool.color_name}` : ''}
+          </p>
+        )}
+      </div>
+    );
+  }
+
+  // Error state
+  if (writeStatus === 'error') {
+    return (
+      <div className="flex flex-col items-center text-center space-y-4">
+        <div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center">
+          <svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+          </svg>
+        </div>
+        <p className="text-red-400 font-medium">{writeMessage}</p>
+        <button
+          onClick={onRetry}
+          className="px-4 py-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-white text-sm rounded transition-colors"
+        >
+          {t('spoolbuddy.writeTag.tryAgain', 'Try Again')}
+        </button>
+      </div>
+    );
+  }
+
+  // Writing state
+  if (writeStatus === 'writing') {
+    return (
+      <div className="flex flex-col items-center text-center space-y-4">
+        <div className="relative w-16 h-16">
+          <div className="absolute inset-0 rounded-full border-2 border-bambu-green/30 animate-ping" />
+          <div className="absolute inset-2 rounded-full border-2 border-bambu-green/50 animate-pulse" />
+          <div className="absolute inset-0 flex items-center justify-center">
+            <NfcIcon className="w-8 h-8 text-bambu-green" />
+          </div>
+        </div>
+        <p className="text-bambu-green font-medium">{t('spoolbuddy.writeTag.writing', 'Writing tag...')}</p>
+        <p className="text-zinc-500 text-xs">{writeMessage}</p>
+        <button
+          onClick={onCancel}
+          className="px-4 py-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-400 text-sm rounded transition-colors"
+        >
+          {t('spoolbuddy.writeTag.cancel', 'Cancel')}
+        </button>
+      </div>
+    );
+  }
+
+  // Device offline
+  if (!deviceOnline) {
+    return (
+      <div className="flex flex-col items-center text-center space-y-3">
+        <NfcIcon className="w-12 h-12 text-zinc-600" />
+        <p className="text-zinc-500 text-sm">{t('spoolbuddy.writeTag.deviceOffline', 'SpoolBuddy is offline')}</p>
+      </div>
+    );
+  }
+
+  // No spool selected
+  if (!selectedSpool) {
+    return (
+      <div className="flex flex-col items-center text-center space-y-3">
+        <NfcIcon className="w-12 h-12 text-zinc-600" />
+        <p className="text-zinc-400 text-sm">{t('spoolbuddy.writeTag.selectSpool', 'Select a spool, then place a blank NTAG on the reader')}</p>
+      </div>
+    );
+  }
+
+  // Spool selected — show summary + write button
+  const spoolColor = selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666';
+
+  return (
+    <div className="flex flex-col items-center text-center space-y-4 w-full">
+      {/* NFC indicator */}
+      <div className="relative w-16 h-16">
+        {tagOnReader ? (
+          <>
+            <div className="absolute inset-0 rounded-full bg-bambu-green/10" />
+            <div className="absolute inset-0 flex items-center justify-center">
+              <NfcIcon className="w-8 h-8 text-bambu-green" />
+            </div>
+          </>
+        ) : (
+          <>
+            <div className="absolute inset-0 rounded-full border-2 border-zinc-600 animate-pulse" />
+            <div className="absolute inset-0 flex items-center justify-center">
+              <NfcIcon className="w-8 h-8 text-zinc-500" />
+            </div>
+          </>
+        )}
+      </div>
+
+      {tagOnReader ? (
+        <div className="space-y-1">
+          <p className="text-bambu-green text-sm font-medium">{t('spoolbuddy.writeTag.tagReady', 'Tag detected — ready to write')}</p>
+          {tagUid && <p className="text-zinc-500 text-xs font-mono">{tagUid}</p>}
+        </div>
+      ) : (
+        <p className="text-zinc-400 text-sm">{t('spoolbuddy.writeTag.placeTag', 'Place an NTAG on the reader')}</p>
+      )}
+
+      {/* Selected spool summary */}
+      <div className="w-full bg-bambu-dark-secondary rounded-lg p-3 space-y-2">
+        <div className="flex items-center gap-3">
+          <div className="w-8 h-8 rounded-full border border-white/10 shrink-0" style={{ backgroundColor: spoolColor }} />
+          <div className="text-left min-w-0">
+            <p className="text-white text-sm font-medium truncate">
+              {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+            </p>
+            {selectedSpool.color_name && <p className="text-zinc-400 text-xs">{selectedSpool.color_name}</p>}
+          </div>
+        </div>
+        <div className="text-xs text-zinc-500">{selectedSpool.label_weight}g</div>
+      </div>
+
+      {/* Replace warning */}
+      {isReplace && selectedSpool.tag_uid && (
+        <p className="text-yellow-500/80 text-xs">
+          {t('spoolbuddy.writeTag.replaceWarning', 'Old tag will be unlinked. New tag will replace it.')}
+        </p>
+      )}
+
+      {/* Write button */}
+      <button
+        onClick={onWrite}
+        disabled={!canWrite}
+        className="w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors text-sm"
+      >
+        {isReplace
+          ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
+          : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
+      </button>
+    </div>
+  );
+}
+
+// --- NFC icon ---
+function NfcIcon({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
+      <path strokeLinecap="round" strokeLinejoin="round" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
+      <path strokeLinecap="round" strokeLinejoin="round" d="M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" />
+    </svg>
+  );
+}

+ 14 - 0
spoolbuddy/daemon/api_client.py

@@ -168,3 +168,17 @@ class APIClient:
                 "raw_adc": raw_adc,
                 "raw_adc": raw_adc,
             },
             },
         )
         )
+
+    async def write_tag_result(
+        self, device_id: str, spool_id: int, tag_uid: str, success: bool, message: str | None = None
+    ) -> dict | None:
+        return await self._post(
+            "/nfc/write-result",
+            {
+                "device_id": device_id,
+                "spool_id": spool_id,
+                "tag_uid": tag_uid,
+                "success": success,
+                "message": message,
+            },
+        )

+ 23 - 1
spoolbuddy/daemon/main.py

@@ -15,7 +15,7 @@ from . import __version__
 from .api_client import APIClient
 from .api_client import APIClient
 from .config import Config
 from .config import Config
 from .display_control import DisplayControl
 from .display_control import DisplayControl
-from .nfc_reader import NFCReader
+from .nfc_reader import NFCReader, NFCState
 from .scale_reader import ScaleReader
 from .scale_reader import ScaleReader
 
 
 logging.basicConfig(
 logging.basicConfig(
@@ -64,6 +64,20 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
                     tag_uid=event_data["tag_uid"],
                     tag_uid=event_data["tag_uid"],
                 )
                 )
 
 
+            # Check for pending write command
+            pending = shared.get("pending_write")
+            if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00:
+                logger.info("Executing pending tag write for spool %d", pending["spool_id"])
+                success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
+                await api.write_tag_result(
+                    device_id=config.device_id,
+                    spool_id=pending["spool_id"],
+                    tag_uid=nfc.current_uid or "",
+                    success=success,
+                    message=msg,
+                )
+                shared.pop("pending_write", None)
+
             await asyncio.sleep(config.nfc_poll_interval)
             await asyncio.sleep(config.nfc_poll_interval)
     finally:
     finally:
         nfc.close()
         nfc.close()
@@ -143,6 +157,14 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
                     logger.warning("Tare command received but scale not available")
                     logger.warning("Tare command received but scale not available")
                 # Skip calibration sync — this heartbeat response predates the tare
                 # Skip calibration sync — this heartbeat response predates the tare
                 continue
                 continue
+            elif cmd == "write_tag":
+                write_payload = result.get("pending_write_payload")
+                if write_payload:
+                    shared["pending_write"] = {
+                        "spool_id": write_payload["spool_id"],
+                        "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
+                    }
+                    logger.info("Write tag command received for spool %d", write_payload["spool_id"])
 
 
             tare = result.get("tare_offset", config.tare_offset)
             tare = result.get("tare_offset", config.tare_offset)
             cal = result.get("calibration_factor", config.calibration_factor)
             cal = result.get("calibration_factor", config.calibration_factor)

+ 38 - 0
spoolbuddy/daemon/nfc_reader.py

@@ -86,6 +86,44 @@ class NFCReader:
         except Exception:
         except Exception:
             pass
             pass
 
 
+    @property
+    def current_sak(self) -> int | None:
+        return self._current_sak
+
+    def write_ntag(self, data: bytes) -> tuple[bool, str]:
+        """Write raw NDEF bytes to currently present NTAG tag.
+
+        Requires tag in TAG_PRESENT state with SAK=0x00.
+        Returns (success, message).
+        """
+        if self._state != NFCState.TAG_PRESENT:
+            return False, "No tag present"
+        if self._current_sak != 0x00:
+            return False, f"Not an NTAG (SAK=0x{self._current_sak:02X})"
+        if not self._nfc:
+            return False, "NFC reader not available"
+
+        try:
+            # Reactivate card before writing
+            result = self._nfc.reactivate_card()
+            if result is None:
+                return False, "Failed to reactivate card for write"
+
+            uid_bytes, sak = result
+            if uid_bytes.hex().upper() != self._current_uid:
+                return False, "Tag UID changed during write"
+
+            # Write starting at page 4
+            success = self._nfc.ntag_write_pages(start_page=4, data=data)
+            if success:
+                logger.info("NTAG write successful: %d bytes to tag %s", len(data), self._current_uid)
+                return True, "Write successful"
+            else:
+                return False, "Write or verification failed"
+        except Exception as e:
+            logger.error("NTAG write error: %s", e)
+            return False, f"Write error: {e}"
+
     def poll(self) -> tuple[str, dict | None]:
     def poll(self) -> tuple[str, dict | None]:
         """Poll for tag. Returns (event_type, event_data).
         """Poll for tag. Returns (event_type, event_data).
 
 

+ 63 - 0
spoolbuddy/scripts/read_tag.py

@@ -455,6 +455,69 @@ class PN5180:
 
 
         return blocks
         return blocks
 
 
+    def ntag_write_page(self, page: int, data: bytes) -> bool:
+        """Write 4 bytes to a single NTAG page.
+
+        NTAG WRITE command: 0xA2 + page_number + 4 bytes data.
+        CRC disabled (same as reads). Returns True on ACK (0x0A).
+        """
+        if len(data) != 4:
+            return False
+
+        # Disable CRC
+        self.write_reg_and(0x19, 0xFFFFFFFE)  # TX CRC off
+        self.write_reg_and(0x12, 0xFFFFFFFE)  # RX CRC off
+
+        # Clear IRQs and set transceive mode
+        self.write_reg(0x03, 0xFFFFFFFF)
+        self.set_transceive_mode()
+        time.sleep(0.001)
+
+        # WRITE command: 0xA2 + page + 4 bytes
+        self.send_data([0xA2, page] + list(data))
+        time.sleep(0.005)
+
+        # Check for ACK: NTAG ACK is 4-bit 0x0A
+        rx_status = self.read_reg(0x13)
+        rx_len = rx_status & 0x1FF
+        if rx_len < 1:
+            return False
+
+        ack = self.read_data(1)
+        return ack[0] == 0x0A
+
+    def ntag_write_pages(self, start_page: int, data: bytes) -> bool:
+        """Write data to consecutive NTAG pages starting at start_page.
+
+        Pads last chunk to 4 bytes. Verifies by reading back.
+        Returns True if write + verify succeeded.
+        """
+        # Pad to 4-byte boundary
+        padded = bytearray(data)
+        while len(padded) % 4 != 0:
+            padded.append(0x00)
+
+        # Write page by page
+        for i in range(0, len(padded), 4):
+            page = start_page + (i // 4)
+            chunk = bytes(padded[i : i + 4])
+            if not self.ntag_write_page(page, chunk):
+                return False
+            time.sleep(0.002)
+
+        # Reactivate card for verification read
+        result = self.reactivate_card()
+        if result is None:
+            return False
+
+        # Read back and verify
+        num_pages = len(padded) // 4
+        readback = self.ntag_read_pages(start_page, num_pages)
+        if readback is None:
+            return False
+
+        return readback[: len(data)] == data
+
     def read_ntag(self, uid: bytes) -> bytes | None:
     def read_ntag(self, uid: bytes) -> bytes | None:
         """Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed.
         """Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed.
 
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C8l0CpBG.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Dtmjz8ii.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-ug6uJJTK.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B2SGH3be.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Dtmjz8ii.css">
+    <script type="module" crossorigin src="/assets/index-ug6uJJTK.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C8l0CpBG.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов