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.
This commit is contained in:
maziggy
2026-03-02 13:56:15 +01:00
parent e1795ff3b7
commit f943420ea2
28 changed files with 2228 additions and 435 deletions
+1
View File
@@ -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.
### 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 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.
+130 -1
View File
@@ -26,6 +26,8 @@ from backend.app.schemas.spoolbuddy import (
TagRemovedRequest,
TagScannedRequest,
UpdateSpoolWeightRequest,
WriteTagRequest,
WriteTagResultRequest,
)
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
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()
@@ -186,6 +199,7 @@ async def device_heartbeat(
return HeartbeatResponse(
pending_command=pending,
pending_write_payload=pending_write,
tare_offset=device.tare_offset,
calibration_factor=device.calibration_factor,
display_brightness=device.display_brightness,
@@ -256,6 +270,121 @@ async def nfc_tag_removed(
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 ---
+6
View File
@@ -1354,6 +1354,12 @@ async def run_migrations(conn):
except OperationalError:
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():
"""Seed default notification templates if they don't exist."""
+2 -1
View File
@@ -1,6 +1,6 @@
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 backend.app.core.database import Base
@@ -28,6 +28,7 @@ class SpoolBuddyDevice(Base):
last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)
last_seen: Mapped[datetime | None] = mapped_column(DateTime)
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)
scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
uptime_s: Mapped[int] = mapped_column(Integer, default=0)
+14
View File
@@ -60,6 +60,7 @@ class HeartbeatRequest(BaseModel):
class HeartbeatResponse(BaseModel):
pending_command: str | None = None
pending_write_payload: dict | None = None
tare_offset: int
calibration_factor: float
display_brightness: int = 100
@@ -123,6 +124,19 @@ class CalibrationResponse(BaseModel):
# --- 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):
brightness: int = Field(ge=0, le=100)
blank_timeout: int = Field(ge=0)
+103
View File
@@ -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
@@ -308,6 +308,258 @@ class TestNfcEndpoints:
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
# ============================================================================
+142
View File
@@ -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
View File
@@ -28,6 +28,7 @@ import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -122,6 +123,7 @@ function App() {
<Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
<Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
<Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
<Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
<Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
<Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
</Route>
@@ -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
View File
@@ -4920,4 +4920,16 @@ export const spoolbuddyApi = {
checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
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: '{}',
}),
};
@@ -22,6 +22,17 @@ const navItems = [
</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',
labelKey: 'spoolbuddy.nav.settings',
+9
View File
@@ -276,6 +276,15 @@ export function useWebSocket() {
window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
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':
window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
break;
+32
View File
@@ -3579,6 +3579,7 @@ export default {
dashboard: 'Dashboard',
ams: 'AMS',
inventory: 'Inventar',
writeTag: 'Schreiben',
settings: 'Einstellungen',
},
status: {
@@ -3724,5 +3725,36 @@ export default {
upToDate: 'Aktuell',
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
View File
@@ -3584,6 +3584,7 @@ export default {
dashboard: 'Dashboard',
ams: 'AMS',
inventory: 'Inventory',
writeTag: 'Write',
settings: 'Settings',
},
status: {
@@ -3729,5 +3730,36 @@ export default {
upToDate: 'Up to date',
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
View File
@@ -3547,6 +3547,7 @@ export default {
dashboard: 'Tableau de bord',
ams: 'AMS',
inventory: 'Inventaire',
writeTag: 'Écrire',
settings: 'Paramètres',
},
status: {
@@ -3673,5 +3674,36 @@ export default {
upToDate: 'À jour',
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
View File
@@ -2935,6 +2935,7 @@ export default {
dashboard: 'Dashboard',
ams: 'AMS',
inventory: 'Inventario',
writeTag: 'Scrivi',
settings: 'Impostazioni',
},
status: {
@@ -3061,5 +3062,36 @@ export default {
upToDate: 'Aggiornato',
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
View File
@@ -3413,6 +3413,7 @@ export default {
dashboard: 'ダッシュボード',
ams: 'AMS',
inventory: 'インベントリ',
writeTag: '書込み',
settings: '設定',
},
status: {
@@ -3558,5 +3559,36 @@ export default {
upToDate: '最新です',
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
View File
@@ -3545,6 +3545,7 @@ export default {
dashboard: 'Painel',
ams: 'AMS',
inventory: 'Inventário',
writeTag: 'Escrever',
settings: 'Configurações',
},
status: {
@@ -3671,5 +3672,36 @@ export default {
upToDate: 'Atualizado',
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',
},
},
};
@@ -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
View File
@@ -168,3 +168,17 @@ class APIClient:
"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
View File
@@ -15,7 +15,7 @@ from . import __version__
from .api_client import APIClient
from .config import Config
from .display_control import DisplayControl
from .nfc_reader import NFCReader
from .nfc_reader import NFCReader, NFCState
from .scale_reader import ScaleReader
logging.basicConfig(
@@ -64,6 +64,20 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
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)
finally:
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")
# Skip calibration sync — this heartbeat response predates the tare
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)
cal = result.get("calibration_factor", config.calibration_factor)
+38
View File
@@ -86,6 +86,44 @@ class NFCReader:
except Exception:
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]:
"""Poll for tag. Returns (event_type, event_data).
+63
View File
@@ -455,6 +455,69 @@ class PN5180:
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:
"""Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<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>
<body>
<div id="root"></div>