diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..a97603e9f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:doc.tigertag.io)", + "WebFetch(domain:github.com)" + ] + } +} diff --git a/.claude/tigertag-implementation-log.md b/.claude/tigertag-implementation-log.md new file mode 100644 index 000000000..59cfa7c12 --- /dev/null +++ b/.claude/tigertag-implementation-log.md @@ -0,0 +1,109 @@ +# TigerTag Integration - Implementation Log + +**Date:** 2026-02-13 +**Branch:** master + +--- + +## Status: All 4 Phases Implemented and Verified + +- All code written and committed. +- Frontend build passes (TypeScript clean). +- All 223 integration tests pass (SQLite). +- Manual and NFC hardware testing still pending. + +--- + +## Bugs Fixed During Verification + +| File | Issue | Fix | +|------|-------|-----| +| `client/src/components/header/index.tsx` | Used non-existent `RefineThemedLayoutV2HeaderProps` export | Reverted to `RefineThemedLayoutHeaderProps` | +| `client/src/pages/spools/show.tsx` | Used `queryResult` instead of `query` from `useShow()` | Changed to `const { query } = useShow()` | +| `client/src/pages/spools/show.tsx` | Added unnecessary `IResourceComponentsProps` and `React` import | Removed; matches upstream component signature | + +--- + +## Files Changed + +### New Files Created (8) + +| File | Phase | Description | +|------|-------|-------------| +| `spoolman/tigertagdb.py` | 1 | TigerTag API client, Pydantic models, sync scheduler, cache | +| `spoolman/tigertag_codec.py` | 3 | NTAG213 binary encoder/decoder (144 bytes, struct-based) | +| `spoolman/nfc_service.py` | 3 | NFC reader singleton wrapping nfcpy | +| `spoolman/tigertag_lookup.py` | 3 | Spool-to-TigerTag matching and reverse mapping | +| `spoolman/api/v1/nfc.py` | 3 | NFC API endpoints (status/read/write) | +| `client/src/utils/nfc.ts` | 4 | TS types, React Query hooks, Web NFC declarations | +| `client/src/components/nfcScannerModal.tsx` | 4 | NFC tag scanner modal (Browser + Server modes) | +| `client/src/components/nfcWriteModal.tsx` | 4 | NFC tag writer modal with data preview | + +### Modified Files (11) + +| File | Phase | What Changed | +|------|-------|-------------| +| `spoolman/env.py` | 1,3 | Added 6 env config functions (tigertag + nfc) | +| `spoolman/externaldb.py` | 1 | Added `source` field to `ExternalFilament` model | +| `spoolman/api/v1/externaldb.py` | 1 | Changed to JSONResponse merging SpoolmanDB + TigerTag | +| `spoolman/main.py` | 1,3 | Added tigertagdb sync + NFC service init on startup | +| `spoolman/api/v1/router.py` | 3 | Registered nfc router | +| `pyproject.toml` | 3 | Added optional `nfc = ["nfcpy>=1.0"]` dependency | +| `client/src/utils/queryExternalDB.ts` | 2 | Added `source` field to ExternalFilament interface | +| `client/src/components/filamentImportModal.tsx` | 2 | Added colored source Tag badges, searchText filtering | +| `client/public/locales/en/common.json` | 2,4 | Added `external.*` and `nfc.*` translation keys | +| `client/src/components/header/index.tsx` | 4 | Added NfcScannerModal alongside QRCodeScannerModal | +| `client/src/pages/spools/show.tsx` | 4 | Added "Encode to NFC" button + NfcWriteModal | + +--- + +## Environment Variables Added + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPOOLMAN_TIGERTAG_ENABLED` | `FALSE` | Enable TigerTag external DB | +| `SPOOLMAN_TIGERTAG_API_URL` | `https://api.tigertag.io/` | TigerTag API base URL | +| `SPOOLMAN_TIGERTAG_SYNC_INTERVAL` | `3600` | Sync interval in seconds | +| `SPOOLMAN_NFC_ENABLED` | `FALSE` | Enable server-side NFC reader | +| `SPOOLMAN_NFC_READER_TYPE` | `nfcpy` | NFC reader library | +| `SPOOLMAN_NFC_DEVICE` | `None` (auto) | NFC device path | + +--- + +## API Endpoints Added + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/nfc/status` | Reader status + enabled flag | +| `POST` | `/api/v1/nfc/read` | Read tag, decode TigerTag, match spool | +| `POST` | `/api/v1/nfc/write` | Encode spool as TigerTag, write to tag | + +--- + +## Remaining TODO + +1. **Manual testing with `SPOOLMAN_TIGERTAG_ENABLED=TRUE`**: + - Check logs for "Syncing TigerTag DB" + - `GET /api/v1/external/filament` should return entries with both sources + - Filament import modal should show colored source badges +2. **NFC testing** (requires hardware): + - `SPOOLMAN_NFC_ENABLED=TRUE` with PN532/ACR122U reader + - `GET /api/v1/nfc/status` returns `connected` + - Write/read cycle via spool detail page + - Browser Web NFC on Chrome Android +3. **Edge cases to verify**: + - TigerTag disabled: no tigertag entries in `/external/filament`, no errors + - NFC disabled: NFC button hidden in UI, `/nfc/status` returns `disabled` + - TigerTag API unreachable: graceful fallback, SpoolmanDB still works + - nfcpy not installed: startup logs warning, NFC endpoints return error + +--- + +## Architecture Notes + +- TigerTag filaments get `id` prefixed as `"tigertag_{id_product}"` to avoid collisions with SpoolmanDB IDs +- TigerTag codec uses Python `struct` module (big-endian) - no external dependencies +- NFC service is a singleton initialized once at startup, thread-safe with lock +- Frontend NFC scanner FloatButton only renders if server NFC or Web NFC is available +- Web NFC (browser) mode writes NDEF URI records (`web+spoolman:s-{id}`) +- Server mode writes raw TigerTag Maker format (144 bytes to NTAG213 pages 4-39) diff --git a/.gitignore b/.gitignore index 9994995df..3c8d009e2 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,8 @@ instance/ # Sphinx documentation docs/_build/ -docs/ +!docs/RELEASE_NOTES.md +!docs/SESSION_LOG.md # PyBuilder .pybuilder/ diff --git a/Dockerfile b/Dockerfile index 94d1612c6..409138f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,14 +22,14 @@ WORKDIR /home/app/spoolman RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project + uv sync --locked --no-install-project --extra nfc # Copy and install app COPY --chown=app:app migrations /home/app/spoolman/migrations COPY --chown=app:app spoolman /home/app/spoolman/spoolman COPY --chown=app:app alembic.ini README.md uv.lock pyproject.toml /home/app/spoolman/ RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked + uv sync --locked --extra nfc FROM python:3.14-slim-bookworm AS python-runner @@ -37,9 +37,10 @@ LABEL org.opencontainers.image.source=https://github.com/Donkie/Spoolman LABEL org.opencontainers.image.description="Keep track of your inventory of 3D-printer filament spools." LABEL org.opencontainers.image.licenses=MIT -# Install gosu for privilege dropping +# Install gosu for privilege dropping and libusb for NFC reader support RUN apt-get update && apt-get install -y \ gosu \ + libusb-1.0-0 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/PR_880_UPDATE.md b/PR_880_UPDATE.md new file mode 100644 index 000000000..175a908db --- /dev/null +++ b/PR_880_UPDATE.md @@ -0,0 +1,69 @@ +## Qidi NFC Tag Support + +This update adds support for **Qidi RFID tags** (MIFARE Classic 1K / FM11RF08S) alongside the existing TigerTag and OpenPrintTag formats. + +### What's new + +**Qidi tags** are ISO 14443A MIFARE Classic 1K tags used by Qidi 3D printers and the QIDIBOX filament management system. They store a simple 3-byte payload: material code, color code, and manufacturer ID. Spoolman now reads, writes, and auto-detects these tags through the same NFC infrastructure used for TigerTag and OpenPrintTag. + +### How it works + +- **Auto-detection**: The server-side NFC reader automatically detects whether a scanned tag is NTAG213 (TigerTag), NFC-V (OpenPrintTag), or MIFARE Classic (Qidi) and routes to the correct decoder. +- **UID-based binding**: Since Qidi tags only store material + color (no unique spool ID), binding uses the tag's hardware UID. Scan once to bind, and all future scans of that physical tag resolve to the same spool. +- **Fuzzy matching**: If no UID binding exists, Spoolman fuzzy-matches by material type and color hex against existing spools. +- **Auto-create**: Unrecognized Qidi tags can auto-create a spool with the correct Qidi vendor, material name (e.g. "Qidi PLA Silk"), and color. +- **Writing**: Spools can be written to blank MIFARE Classic 1K tags in Qidi format from the web UI. +- **Lookup endpoint**: The `/api/v1/nfc/lookup` endpoint accepts `tag_type: "qidi"` or auto-detects from 16-byte block data, so Klipper integrations work out of the box. + +### Supported Qidi materials & colors + +- **35 materials**: PLA, PLA Matte, PLA Silk, PLA-CF, ABS, ABS-GF, ASA, PA-CF, PAHT-CF, PETG, PETG-CF, PPS-CF, TPU, PVA, and more +- **24 colors**: White, Black, Red, Blue, Yellow, Orange, Green, Purple, Pink, and more — each mapped to an RGB hex value + +### Authentication + +Qidi tags use MIFARE Classic Crypto-1 authentication with Key A. Two keys are tried in sequence: +1. Qidi custom key: `D3:F7:D3:F7:D3:F7` (factory Qidi tags) +2. Default key: `FF:FF:FF:FF:FF:FF` (blank MIFARE Classic tags) + +### Files changed + +| File | Description | +|------|-------------| +| `spoolman/qidi_codec.py` | **New** — Material/color lookup tables, 3-byte encode/decode, auto-detection | +| `spoolman/qidi_lookup.py` | **New** — UID-based spool binding, fuzzy matching, auto-create | +| `spoolman/nfc_service.py` | Added `read_tag_auto()` with MIFARE Classic detection, dual-key auth read/write | +| `spoolman/api/v1/nfc.py` | Added `QidiTagDataResponse`, Qidi handling in all NFC endpoints | +| `client/src/utils/nfc.ts` | Added `QidiTagData` interface, updated request/response types | +| `client/src/components/nfcScannerModal.tsx` | Shows Qidi tag summary, creates spools from Qidi data | +| `client/src/components/nfcBindModal.tsx` | Supports Qidi UID-based tag binding | +| `client/src/components/nfcWriteModal.tsx` | Tag format selector (TigerTag / Qidi) | +| `client/public/locales/en/common.json` | New translation keys for Qidi UI | +| `README.md` | Updated NFC feature list | + +### API examples + +**Look up a Qidi tag (auto-detect):** +```bash +curl -X POST /api/v1/nfc/lookup \ + -d '{"raw_data_b64": "ARIBAAAAAAAAAAAAAAAAAA==", "nfc_tag_uid": "A1B2C3D4"}' +``` + +**Auto-create spool from Qidi tag:** +```bash +curl -X POST /api/v1/nfc/lookup \ + -d '{"raw_data_b64": "ARIBAAAAAAAAAAAAAAAAAA==", "tag_type": "qidi", "nfc_tag_uid": "DEADBEEF", "auto_create": true}' +``` + +**Write spool as Qidi tag:** +```bash +curl -X POST /api/v1/nfc/write \ + -d '{"spool_id": 1, "tag_format": "qidi"}' +``` + +### Reference + +- [Qidi RFID Wiki](https://wiki.qidi3d.com/en/QIDIBOX/RFID) +- Tag chip: FM11RF08S (MIFARE Classic 1K compatible) +- Data location: Sector 1, Block 0 (absolute block 4) +- Protocol: ISO/IEC 14443-A, 13.56 MHz diff --git a/README.md b/README.md index a6a796cf8..bcd689776 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo * Add custom fields to tailor information to your specific needs. * Print labels with QR codes for easy spool identification and tracking. * Contribute to its translation into 18 languages via [Weblate](https://hosted.weblate.org/projects/spoolman/). +* **NFC Spool Identification**: Scan NFC tags to instantly identify and select spools. Supports three tag standards: + * [TigerTag](https://tigertag.io/) (ISO 14443A / NTAG213) — binary format with external product database lookup. + * [OpenPrintTag](https://openprinttag.org/) (ISO 15693 / NFC-V) — Prusa's NDEF/CBOR standard with per-spool UUIDs. + * [Qidi](https://wiki.qidi3d.com/en/QIDIBOX/RFID) (ISO 14443A / MIFARE Classic 1K) — Qidi filament tags with material and color identification. + * Browser-based NFC scanning via the Web NFC API, or server-side with a USB reader. + * Automatic spool creation from tag data when scanning unrecognized tags. + * External integration endpoint (`POST /api/v1/nfc/lookup`) for Klipper NFC daemons and other clients. * **Database Support**: SQLite, PostgreSQL, MySQL, and CockroachDB. * **Multi-Printer Management**: Handles spool updates from several printers simultaneously. * **Advanced Monitoring**: Integrate with [Prometheus](https://prometheus.io/) for detailed historical analysis of filament usage, helping you track and optimize your printing processes. See the [Wiki](https://github.com/Donkie/Spoolman/wiki/Filament-Usage-History) for instructions on how to set it up. diff --git a/client/.env.production b/client/.env.production new file mode 100644 index 000000000..03754014a --- /dev/null +++ b/client/.env.production @@ -0,0 +1 @@ +VITE_APIURL=/api/v1 diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..644314bfb 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -395,5 +395,68 @@ "new_location": "New Location", "no_location": "No Location", "no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!" + }, + "external": { + "source_spoolmandb": "SpoolmanDB", + "source_tigertag": "TigerTag" + }, + "nfc": { + "scan_title": "NFC Tag Scanner", + "scan_description": "Scan an NFC tag to view details about the spool.", + "encode_button": "Encode to NFC", + "encode_title": "Encode Spool to NFC Tag", + "write_success": "Tag written successfully!", + "write_error": "Failed to write tag.", + "no_match": "No matching spool found for this tag.", + "create_from_tag": "Create Spool from Tag", + "create_from_tag_description": "No matching spool found. You can create a new spool from the tag data below.", + "creating_spool": "Creating spool...", + "create_success": "Spool created successfully!", + "create_error": "Failed to create spool from tag data.", + "tag_color": "Color", + "tag_diameter": "Diameter", + "tag_weight": "Weight", + "tag_nozzle_temp": "Nozzle Temp", + "tag_bed_temp": "Bed Temp", + "mode_browser": "Browser", + "mode_server": "Server", + "unsupported": "NFC is not supported in this browser.", + "place_tag": "Place tag on reader...", + "reading": "Reading tag...", + "writing": "Writing tag...", + "user_message": "User Message", + "user_message_help": "Optional message (max 28 characters)", + "preview_title": "Data Preview", + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "disabled": "Disabled" + }, + "browser_ndef_warning": "Warning: Browser NFC writes use NDEF format. Tags written this way will NOT be recognized by the TigerTag app, which expects raw binary on pages 4-39. Use Server mode for TigerTag-compatible writes, or Download Raw Binary for use with an external NFC tool.", + "download_raw_binary": "Download Raw Binary", + "download_success": "Binary file downloaded successfully.", + "browser_write_success": "TigerTag data written to NFC tag via NDEF.", + "error": { + "no_reader": "No NFC reader detected.", + "read_failed": "Failed to read tag.", + "write_failed": "Failed to write tag.", + "not_supported": "Web NFC is not supported in this browser. Use the Server mode with a connected reader.", + "permission_denied": "NFC permission denied.", + "encode_failed": "Failed to encode TigerTag data." + }, + "bind_title": "Link NFC Tag to Spool", + "bind_button": "Link NFC Tag", + "bind_description": "Scan an existing NFC tag to link it to this spool. Future scans of this tag will automatically select this spool.", + "bind_confirm_description": "Tag scanned. Review the tag data below and confirm to link it to this spool.", + "bind_binding": "Linking...", + "bind_success": "Tag linked to spool successfully!", + "bind_error": "Failed to link tag to spool.", + "bind_scan_again": "Scan Again", + "bind_no_tigertag": "No TigerTag data found on this tag. Only TigerTag format is supported for linking.", + "tag_format": "Tag Format", + "tag_format_label": "Tag Format", + "tag_material": "Material", + "tag_material_type": "Material Type", + "qidi_write_info": "Qidi tags store only material and color. The tag will be written with the closest matching Qidi material and color codes." } } diff --git a/client/src/components/filamentImportModal.tsx b/client/src/components/filamentImportModal.tsx index fe0cdebf7..c0434c1b6 100644 --- a/client/src/components/filamentImportModal.tsx +++ b/client/src/components/filamentImportModal.tsx @@ -1,5 +1,6 @@ import { useTranslate } from "@refinedev/core"; -import { Form, Modal, Select } from "antd"; +import { Form, Modal, Select, Tag } from "antd"; +import React from "react"; import { Trans } from "react-i18next"; import { formatFilamentLabel } from "../pages/spools/functions"; import { searchMatches } from "../utils/filtering"; @@ -16,20 +17,32 @@ export function FilamentImportModal(props: { const externalFilaments = useGetExternalDBFilaments(); const filamentOptions = externalFilaments.data?.map((item) => { + const textLabel = formatFilamentLabel( + item.name, + item.diameter, + item.manufacturer, + item.material, + item.weight, + item.spool_type + ); + const sourceTag = + item.source === "tigertag" ? ( + {t("external.source_tigertag")} + ) : ( + {t("external.source_spoolmandb")} + ); return { - label: formatFilamentLabel( - item.name, - item.diameter, - item.manufacturer, - item.material, - item.weight, - item.spool_type, + label: ( + + {sourceTag} {textLabel} + ), + searchText: textLabel, value: item.id, item: item, }; }) ?? []; - filamentOptions.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); + filamentOptions.sort((a, b) => a.searchText.localeCompare(b.searchText, undefined, { sensitivity: "base" })); return ( typeof option?.label === "string" && searchMatches(input, option?.label)} + filterOption={(input, option) => + typeof option?.searchText === "string" && searchMatches(input, option.searchText) + } /> diff --git a/client/src/components/header/index.tsx b/client/src/components/header/index.tsx index 98bd8020b..0dd65f94d 100644 --- a/client/src/components/header/index.tsx +++ b/client/src/components/header/index.tsx @@ -6,6 +6,7 @@ import React, { useContext } from "react"; import { ColorModeContext } from "../../contexts/color-mode"; import { languages } from "../../i18n"; +import NfcScannerModal from "../nfcScannerModal"; import QRCodeScannerModal from "../qrCodeScanner"; const { useToken } = theme; @@ -62,6 +63,7 @@ export const Header = ({ sticky }: RefineThemedLayoutHeaderProps) => { defaultChecked={mode === "dark"} /> + ); diff --git a/client/src/components/nfcBindModal.tsx b/client/src/components/nfcBindModal.tsx new file mode 100644 index 000000000..40236d0df --- /dev/null +++ b/client/src/components/nfcBindModal.tsx @@ -0,0 +1,365 @@ +import { LinkOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Alert, Button, Descriptions, Modal, Segmented, Space, Spin, Typography } from "antd"; +import React, { useCallback, useState } from "react"; +import { QidiTagData, TigerTagData, isWebNfcSupported, useNfcBind, useNfcRead, useNfcStatus } from "../utils/nfc"; +import { decodeTigerTag, isTigerTag } from "../utils/tigertagCodec"; +import { ISpool } from "../pages/spools/model"; + +const { Text } = Typography; + +interface NfcBindModalProps { + spool?: ISpool; + visible: boolean; + onClose: () => void; + onBound?: () => void; +} + +/** + * Renders decoded TigerTag data as a compact Descriptions panel. + */ +const TagDataSummary: React.FC<{ tagData: TigerTagData; t: (key: string) => string }> = ({ tagData, t }) => { + const diameter = + tagData.diameter_mm > 0 + ? `${tagData.diameter_mm} mm` + : tagData.id_diameter === 1 + ? "1.75 mm" + : tagData.id_diameter === 2 + ? "2.85 mm" + : "—"; + + return ( + + + {tagData.color_hex ? ( + + + #{tagData.color_hex} + + ) : ( + "—" + )} + + {diameter} + + {tagData.weight > 0 ? `${tagData.weight} g` : "—"} + + + {tagData.nozzle_temp > 0 ? `${tagData.nozzle_temp} °C` : "—"} + + + {tagData.bed_temp > 0 ? `${tagData.bed_temp} °C` : "—"} + + + ); +}; + +/** + * Renders decoded Qidi tag data as a compact Descriptions panel. + */ +const QidiTagDataSummary: React.FC<{ qidiData: QidiTagData; t: (key: string) => string }> = ({ qidiData, t }) => ( + + Qidi + {qidiData.material_name} + + {qidiData.color_hex ? ( + + + {qidiData.color_name} + + ) : ( + "—" + )} + + {qidiData.material_type} + +); + +const NfcBindModal: React.FC = ({ spool, visible, onClose, onBound }) => { + const [mode, setMode] = useState<"browser" | "server">("server"); + const [browserScanning, setBrowserScanning] = useState(false); + const [browserError, setBrowserError] = useState(null); + const [scannedTagData, setScannedTagData] = useState(null); + const [scannedQidiData, setScannedQidiData] = useState(null); + const [scannedRawB64, setScannedRawB64] = useState(null); + const [scannedTagUid, setScannedTagUid] = useState(null); + const [scannedTagFormat, setScannedTagFormat] = useState(null); + const t = useTranslate(); + + const nfcStatus = useNfcStatus(); + const nfcReadMutation = useNfcRead(); + const bindMutation = useNfcBind(); + + const serverEnabled = nfcStatus.data?.enabled === true && nfcStatus.data?.status === "connected"; + const webNfcAvailable = isWebNfcSupported(); + + const hasScannedTag = scannedTagData !== null || scannedQidiData !== null; + + const resetState = useCallback(() => { + setBrowserScanning(false); + setBrowserError(null); + setScannedTagData(null); + setScannedQidiData(null); + setScannedRawB64(null); + setScannedTagUid(null); + setScannedTagFormat(null); + bindMutation.reset(); + nfcReadMutation.reset(); + }, [bindMutation, nfcReadMutation]); + + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + const handleServerRead = useCallback(async () => { + setScannedTagData(null); + setScannedQidiData(null); + setScannedRawB64(null); + setScannedTagUid(null); + setScannedTagFormat(null); + bindMutation.reset(); + + const result = await nfcReadMutation.mutateAsync(); + if (result.success) { + setScannedTagFormat(result.tag_format || null); + setScannedTagUid(result.nfc_tag_uid || null); + setScannedRawB64(result.raw_data_b64 || null); + if (result.qidi_data) { + setScannedQidiData(result.qidi_data); + } else if (result.tag_data) { + setScannedTagData(result.tag_data); + } + } + }, [nfcReadMutation, bindMutation]); + + const handleBrowserScan = useCallback(async () => { + if (!window.NDEFReader) { + setBrowserError(t("nfc.error.not_supported")); + return; + } + + setBrowserScanning(true); + setBrowserError(null); + setScannedTagData(null); + setScannedRawB64(null); + bindMutation.reset(); + + try { + const reader = new window.NDEFReader(); + const controller = new AbortController(); + + reader.onreading = (event: NDEFReadingEvent) => { + controller.abort(); + setBrowserScanning(false); + + for (const record of event.message.records) { + if (record.recordType === "tigertag.io:maker" && record.data) { + try { + const tagData = decodeTigerTag(record.data.buffer as ArrayBuffer); + if (isTigerTag(tagData.id_tigertag) && tagData.id_product > 0) { + const colorHex = [tagData.color_r, tagData.color_g, tagData.color_b] + .map((c) => c.toString(16).padStart(2, "0")) + .join(""); + const diameterMm = tagData.id_diameter === 1 ? 1.75 : tagData.id_diameter === 2 ? 2.85 : 0; + + setScannedTagData({ + id_tigertag: tagData.id_tigertag, + id_product: tagData.id_product, + id_material: tagData.id_material, + id_diameter: tagData.id_diameter, + id_brand: tagData.id_brand, + color_hex: colorHex, + weight: tagData.weight, + nozzle_temp: tagData.nozzle_temp, + bed_temp: tagData.bed_temp, + drying_temp: tagData.drying_temp, + drying_duration: tagData.drying_duration, + timestamp: tagData.timestamp, + user_message: tagData.user_message, + diameter_mm: diameterMm, + }); + + // Encode raw data as base64 for the bind request + const bytes = new Uint8Array(record.data.buffer); + const b64 = btoa(String.fromCharCode(...bytes)); + setScannedRawB64(b64); + return; + } + } catch { + // Fall through + } + } + } + + setBrowserError(t("nfc.bind_no_tigertag")); + }; + + reader.onreadingerror = () => { + controller.abort(); + setBrowserScanning(false); + setBrowserError(t("nfc.error.read_failed")); + }; + + await reader.scan({ signal: controller.signal }); + } catch (error) { + setBrowserScanning(false); + if (error instanceof DOMException && error.name === "NotAllowedError") { + setBrowserError(t("nfc.error.permission_denied")); + } else { + setBrowserError(t("nfc.error.read_failed")); + } + } + }, [t, bindMutation]); + + const handleBind = useCallback(async () => { + if (!spool || !hasScannedTag) return; + + if (scannedTagFormat === "qidi" && scannedTagUid) { + // Qidi binding: use UID + await bindMutation.mutateAsync({ + spool_id: spool.id, + tag_type: "qidi", + nfc_tag_uid: scannedTagUid, + raw_data_b64: scannedRawB64 || undefined, + }); + } else if (scannedTagData) { + // TigerTag binding + const request: { spool_id: number; raw_data_b64?: string; id_product?: number; timestamp?: number } = { + spool_id: spool.id, + }; + if (scannedRawB64) { + request.raw_data_b64 = scannedRawB64; + } else { + request.id_product = scannedTagData.id_product; + request.timestamp = scannedTagData.timestamp || 0; + } + await bindMutation.mutateAsync(request); + } + + if (onBound) { + onBound(); + } + }, [spool, hasScannedTag, scannedTagFormat, scannedTagUid, scannedTagData, scannedRawB64, bindMutation, onBound]); + + return ( + + + {t("nfc.bind_description")} + + { + setMode(value as "browser" | "server"); + resetState(); + }} + /> + + {/* Step 1: Scan */} + {!hasScannedTag && ( + <> + {mode === "server" && ( + + + {nfcReadMutation.isError && ( + + )} + + )} + + {mode === "browser" && ( + + {browserScanning ? ( + +
+ + ) : ( + + )} + {browserError && } + + )} + + )} + + {/* Step 2: Confirm binding */} + {hasScannedTag && ( + + + {scannedQidiData ? ( + + ) : scannedTagData ? ( + + ) : null} + + + + + + + {bindMutation.isSuccess && bindMutation.data?.success && ( + + )} + {bindMutation.isSuccess && !bindMutation.data?.success && ( + + )} + {bindMutation.isError && ( + + )} + + )} + + + ); +}; + +export default NfcBindModal; diff --git a/client/src/components/nfcScannerModal.tsx b/client/src/components/nfcScannerModal.tsx new file mode 100644 index 000000000..267bc477e --- /dev/null +++ b/client/src/components/nfcScannerModal.tsx @@ -0,0 +1,382 @@ +import { WifiOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Alert, Button, Descriptions, FloatButton, Modal, Segmented, Space, Spin, Typography } from "antd"; +import React, { useCallback, useState } from "react"; +import { useNavigate } from "react-router"; +import { QidiTagData, TigerTagData, isWebNfcSupported, useNfcCreateFromTag, useNfcRead, useNfcStatus } from "../utils/nfc"; +import { decodeTigerTag, isTigerTag } from "../utils/tigertagCodec"; + +const { Text } = Typography; + +/** + * Renders decoded TigerTag data as a compact Descriptions panel. + */ +const TagDataSummary: React.FC<{ tagData: TigerTagData; t: (key: string) => string }> = ({ tagData, t }) => { + const diameter = + tagData.diameter_mm > 0 + ? `${tagData.diameter_mm} mm` + : tagData.id_diameter === 1 + ? "1.75 mm" + : tagData.id_diameter === 2 + ? "2.85 mm" + : "—"; + + return ( + + + {tagData.color_hex ? ( + + + #{tagData.color_hex} + + ) : ( + "—" + )} + + {diameter} + + {tagData.weight > 0 ? `${tagData.weight} g` : "—"} + + + {tagData.nozzle_temp > 0 ? `${tagData.nozzle_temp} °C` : "—"} + + + {tagData.bed_temp > 0 ? `${tagData.bed_temp} °C` : "—"} + + + ); +}; + +/** + * Renders decoded Qidi tag data as a compact Descriptions panel. + */ +const QidiTagDataSummary: React.FC<{ qidiData: QidiTagData; t: (key: string) => string }> = ({ qidiData, t }) => ( + + Qidi + {qidiData.material_name} + + {qidiData.color_hex ? ( + + + {qidiData.color_name} + + ) : ( + "—" + )} + + {qidiData.material_type} + +); + +const NfcScannerModal: React.FC = () => { + const [visible, setVisible] = useState(false); + const [mode, setMode] = useState<"browser" | "server">("server"); + const [browserScanning, setBrowserScanning] = useState(false); + const [browserError, setBrowserError] = useState(null); + const [unmatchedTagData, setUnmatchedTagData] = useState(null); + const [unmatchedQidiData, setUnmatchedQidiData] = useState(null); + const [unmatchedTagUid, setUnmatchedTagUid] = useState(null); + const [unmatchedTagFormat, setUnmatchedTagFormat] = useState(null); + const t = useTranslate(); + const navigate = useNavigate(); + + const nfcStatus = useNfcStatus(); + const nfcReadMutation = useNfcRead(); + const createFromTagMutation = useNfcCreateFromTag(); + + const serverEnabled = nfcStatus.data?.enabled === true && nfcStatus.data?.status === "connected"; + const webNfcAvailable = isWebNfcSupported(); + + const handleServerRead = useCallback(async () => { + setUnmatchedTagData(null); + setUnmatchedQidiData(null); + setUnmatchedTagUid(null); + setUnmatchedTagFormat(null); + const result = await nfcReadMutation.mutateAsync(); + if (result.success && result.spool_id) { + setVisible(false); + navigate(`/spool/show/${result.spool_id}`); + } else if (result.success && !result.spool_id) { + setUnmatchedTagFormat(result.tag_format || null); + setUnmatchedTagUid(result.nfc_tag_uid || null); + if (result.qidi_data) { + setUnmatchedQidiData(result.qidi_data); + } else if (result.tag_data) { + setUnmatchedTagData(result.tag_data); + } + } + }, [nfcReadMutation, navigate]); + + const handleBrowserScan = useCallback(async () => { + if (!window.NDEFReader) { + setBrowserError(t("nfc.error.not_supported")); + return; + } + + setBrowserScanning(true); + setBrowserError(null); + setUnmatchedTagData(null); + + try { + const reader = new window.NDEFReader(); + const controller = new AbortController(); + + reader.onreading = (event: NDEFReadingEvent) => { + controller.abort(); + setBrowserScanning(false); + + // Look through NDEF records for a TigerTag external type, Spoolman URI, or ID + for (const record of event.message.records) { + // Check for TigerTag NDEF external type record + if (record.recordType === "tigertag.io:maker" && record.data) { + try { + const tagData = decodeTigerTag(record.data.buffer as ArrayBuffer); + if (isTigerTag(tagData.id_tigertag) && tagData.id_product > 0) { + // Convert RGBA to hex string + const colorHex = [tagData.color_r, tagData.color_g, tagData.color_b] + .map((c) => c.toString(16).padStart(2, "0")) + .join(""); + // Derive diameter from id_diameter + const diameterMm = tagData.id_diameter === 1 ? 1.75 : tagData.id_diameter === 2 ? 2.85 : 0; + + setUnmatchedTagData({ + id_tigertag: tagData.id_tigertag, + id_product: tagData.id_product, + id_material: tagData.id_material, + id_diameter: tagData.id_diameter, + id_brand: tagData.id_brand, + color_hex: colorHex, + weight: tagData.weight, + nozzle_temp: tagData.nozzle_temp, + bed_temp: tagData.bed_temp, + drying_temp: tagData.drying_temp, + drying_duration: tagData.drying_duration, + timestamp: tagData.timestamp, + user_message: tagData.user_message, + diameter_mm: diameterMm, + }); + return; + } + } catch { + // Failed to decode, fall through to other record types + } + } + + if (record.recordType === "url" || record.recordType === "text") { + const decoder = new TextDecoder(record.encoding || "utf-8"); + const text = record.data ? decoder.decode(record.data) : ""; + + // Check for spoolman:s-{id} format + const spoolmanMatch = text.match(/web\+spoolman:s-(\d+)/); + if (spoolmanMatch) { + setVisible(false); + navigate(`/spool/show/${spoolmanMatch[1]}`); + return; + } + + // Check for URL format + const urlMatch = text.match(/\/spool\/show\/(\d+)/); + if (urlMatch) { + setVisible(false); + navigate(`/spool/show/${urlMatch[1]}`); + return; + } + } + } + + setBrowserError(t("nfc.no_match")); + }; + + reader.onreadingerror = () => { + controller.abort(); + setBrowserScanning(false); + setBrowserError(t("nfc.error.read_failed")); + }; + + await reader.scan({ signal: controller.signal }); + } catch (error) { + setBrowserScanning(false); + if (error instanceof DOMException && error.name === "NotAllowedError") { + setBrowserError(t("nfc.error.permission_denied")); + } else { + setBrowserError(t("nfc.error.read_failed")); + } + } + }, [t, navigate]); + + const handleCreateFromTag = useCallback(async () => { + if (!unmatchedTagData && !unmatchedQidiData) return; + + let result; + if (unmatchedQidiData && unmatchedTagFormat === "qidi") { + result = await createFromTagMutation.mutateAsync({ + tag_type: "qidi", + material_code: unmatchedQidiData.material_code, + color_code: unmatchedQidiData.color_code, + nfc_tag_uid: unmatchedTagUid || undefined, + }); + } else if (unmatchedTagData) { + result = await createFromTagMutation.mutateAsync({ + id_product: unmatchedTagData.id_product, + id_material: unmatchedTagData.id_material, + id_diameter: unmatchedTagData.id_diameter, + id_brand: unmatchedTagData.id_brand, + color_hex: unmatchedTagData.color_hex, + weight: unmatchedTagData.weight, + nozzle_temp: unmatchedTagData.nozzle_temp, + bed_temp: unmatchedTagData.bed_temp, + drying_temp: unmatchedTagData.drying_temp, + drying_duration: unmatchedTagData.drying_duration, + diameter_mm: unmatchedTagData.diameter_mm, + }); + } else { + return; + } + + if (result.success && result.spool_id) { + setVisible(false); + setUnmatchedTagData(null); + setUnmatchedQidiData(null); + navigate(`/spool/show/${result.spool_id}`); + } + }, [unmatchedTagData, unmatchedQidiData, unmatchedTagFormat, unmatchedTagUid, createFromTagMutation, navigate]); + + // Don't show the button if neither server NFC nor Web NFC is available + if (!serverEnabled && !webNfcAvailable) { + return null; + } + + return ( + <> + setVisible(true)} + icon={} + shape="circle" + style={{ insetInlineEnd: 74 }} + /> + { + setVisible(false); + setBrowserScanning(false); + setBrowserError(null); + setUnmatchedTagData(null); + setUnmatchedQidiData(null); + setUnmatchedTagUid(null); + setUnmatchedTagFormat(null); + }} + footer={null} + title={t("nfc.scan_title")} + > + + {t("nfc.scan_description")} + + setMode(value as "browser" | "server")} + /> + + {mode === "server" && ( + + + {nfcReadMutation.isSuccess && !nfcReadMutation.data?.spool_id && !unmatchedTagData && ( + + )} + {nfcReadMutation.isError && ( + + )} + + )} + + {mode === "browser" && ( + + {browserScanning ? ( + +
+ + ) : ( + + )} + {browserError && !unmatchedTagData && ( + + )} + + )} + + {/* Show tag data and create button when no spool matched */} + {(unmatchedTagData || unmatchedQidiData) && ( + + + {unmatchedQidiData ? ( + + ) : unmatchedTagData ? ( + + ) : null} + + {createFromTagMutation.isSuccess && createFromTagMutation.data?.success && ( + + )} + {(createFromTagMutation.isError || + (createFromTagMutation.isSuccess && !createFromTagMutation.data?.success)) && ( + + )} + + )} + + + + ); +}; + +export default NfcScannerModal; diff --git a/client/src/components/nfcWriteModal.tsx b/client/src/components/nfcWriteModal.tsx new file mode 100644 index 000000000..6a374abe3 --- /dev/null +++ b/client/src/components/nfcWriteModal.tsx @@ -0,0 +1,278 @@ +import { DownloadOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Alert, Button, Descriptions, Input, Modal, Segmented, Space, Spin, Typography } from "antd"; +import React, { useCallback, useState } from "react"; +import { isWebNfcSupported, useNfcEncode, useNfcStatus, useNfcWrite } from "../utils/nfc"; +import { ISpool } from "../pages/spools/model"; +import { encodeTigerTag, mapSpoolToTigerTag } from "../utils/tigertagCodec"; + +const { Text } = Typography; + +interface NfcWriteModalProps { + spool?: ISpool; + visible: boolean; + onClose: () => void; +} + +const NfcWriteModal: React.FC = ({ spool, visible, onClose }) => { + const [modeOverride, setModeOverride] = useState<"browser" | "server" | null>(null); + const [tagFormat, setTagFormat] = useState<"tigertag" | "qidi">("tigertag"); + const [userMessage, setUserMessage] = useState(""); + const [browserWriting, setBrowserWriting] = useState(false); + const [browserResult, setBrowserResult] = useState<{ success: boolean; message: string } | null>(null); + const t = useTranslate(); + + const nfcStatus = useNfcStatus(); + const nfcWriteMutation = useNfcWrite(); + const nfcEncodeMutation = useNfcEncode(); + + const serverEnabled = nfcStatus.data?.enabled === true && nfcStatus.data?.status === "connected"; + const webNfcAvailable = isWebNfcSupported(); + + // Default to server if available, then browser, then browser anyway (for download button) + const mode = modeOverride ?? (serverEnabled ? "server" : "browser"); + const canWrite = mode === "server" ? serverEnabled : webNfcAvailable; + + const handleServerWrite = useCallback(async () => { + if (!spool) return; + + await nfcWriteMutation.mutateAsync({ + spool_id: spool.id, + tag_format: tagFormat, + user_message: tagFormat === "tigertag" ? userMessage : undefined, + }); + }, [spool, userMessage, tagFormat, nfcWriteMutation]); + + const handleBrowserWrite = useCallback(async () => { + if (!spool || !window.NDEFReader) { + setBrowserResult({ success: false, message: t("nfc.error.not_supported") }); + return; + } + + setBrowserWriting(true); + setBrowserResult(null); + + try { + const reader = new window.NDEFReader(); + const tagData = mapSpoolToTigerTag(spool, userMessage); + const binaryPayload = encodeTigerTag(tagData); + + // Write as NDEF external type record with TigerTag binary payload + await reader.write({ + records: [ + { + recordType: "tigertag.io:maker", + data: binaryPayload, + }, + ], + }); + + setBrowserWriting(false); + setBrowserResult({ success: true, message: t("nfc.browser_write_success") }); + } catch (error) { + setBrowserWriting(false); + if (error instanceof DOMException && error.name === "NotAllowedError") { + setBrowserResult({ success: false, message: t("nfc.error.permission_denied") }); + } else { + setBrowserResult({ success: false, message: t("nfc.write_error") }); + } + } + }, [spool, userMessage, t]); + + const handleDownloadBinary = useCallback(async () => { + if (!spool) return; + + try { + const result = await nfcEncodeMutation.mutateAsync({ + spool_id: spool.id, + user_message: userMessage, + }); + + if (result.success && result.binary_b64) { + const binaryString = atob(result.binary_b64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: "application/octet-stream" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `spool-${spool.id}-tigertag.bin`; + a.click(); + URL.revokeObjectURL(url); + setBrowserResult({ success: true, message: t("nfc.download_success") }); + } else { + setBrowserResult({ success: false, message: result.message || t("nfc.error.encode_failed") }); + } + } catch { + setBrowserResult({ success: false, message: t("nfc.error.encode_failed") }); + } + }, [spool, userMessage, nfcEncodeMutation, t]); + + const handleOk = () => { + if (mode === "server") { + handleServerWrite(); + } else { + handleBrowserWrite(); + } + }; + + const filament = spool?.filament; + + return ( + { + onClose(); + setBrowserResult(null); + setUserMessage(""); + setModeOverride(null); + setTagFormat("tigertag"); + }} + okText={nfcWriteMutation.isPending || browserWriting ? t("nfc.writing") : t("nfc.encode_button")} + okButtonProps={{ loading: nfcWriteMutation.isPending || browserWriting, disabled: !canWrite }} + destroyOnClose + > + + setModeOverride(value as "browser" | "server")} + /> + + {mode === "server" && ( +
+ {t("nfc.tag_format_label")} + setTagFormat(value as "tigertag" | "qidi")} + style={{ marginTop: 4 }} + /> +
+ )} + + {filament && ( + <> + {t("nfc.preview_title")} + + {filament.vendor && ( + {filament.vendor.name} + )} + {filament.name && ( + {filament.name} + )} + {filament.material && ( + {filament.material} + )} + {filament.diameter} mm + {filament.color_hex && ( + + + #{filament.color_hex} + + )} + {filament.weight && ( + {filament.weight} g + )} + {filament.settings_extruder_temp && ( + + {filament.settings_extruder_temp} °C + + )} + {filament.settings_bed_temp && ( + + {filament.settings_bed_temp} °C + + )} + + + )} + + {tagFormat === "tigertag" && ( +
+ {t("nfc.user_message")} + setUserMessage(e.target.value.slice(0, 28))} + maxLength={28} + placeholder={t("nfc.user_message_help")} + /> +
+ )} + + {tagFormat === "qidi" && mode === "server" && ( + + )} + + {mode === "server" && (nfcWriteMutation.isPending || browserWriting) && ( + +
+ + )} + + {mode === "server" && nfcWriteMutation.isSuccess && ( + + )} + + {mode === "server" && nfcWriteMutation.isError && ( + + )} + + {mode === "browser" && browserWriting && ( + +
+ + )} + + {mode === "browser" && browserResult && ( + + )} + + {mode === "browser" && ( + <> + + + + )} + + + ); +}; + +export default NfcWriteModal; diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index 9f6ba59d6..314045579 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -1,9 +1,10 @@ -import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; +import { InboxOutlined, LinkOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined, WifiOutlined } from "@ant-design/icons"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { useInvalidate, useShow, useTranslate } from "@refinedev/core"; import { Button, Modal, Typography } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; +import { useState } from "react"; import { ExtraFieldDisplay } from "../../components/extraFields"; import { NumberFieldUnit } from "../../components/numberField"; import SpoolIcon from "../../components/spoolIcon"; @@ -11,6 +12,8 @@ import { enrichText } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; import { getBasePath } from "../../utils/url"; +import NfcBindModal from "../../components/nfcBindModal"; +import NfcWriteModal from "../../components/nfcWriteModal"; import { IFilament } from "../filaments/model"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { ISpool } from "./model"; @@ -41,6 +44,13 @@ export const SpoolShow = () => { return currencyFormatter.format(price); }; + // NFC state + const [nfcWriteModalVisible, setNfcWriteModalVisible] = useState(false); + const [nfcBindModalVisible, setNfcBindModalVisible] = useState(false); + // Always show the NFC button — the modal handles mode availability, + // and the "Download Raw Binary" option works without NFC hardware or Web NFC. + const showNfcButton = true; + // Provides the function to open the spool adjustment modal and the modal component itself const { openSpoolAdjustModal, spoolAdjustModal } = useSpoolAdjustModal(); @@ -133,6 +143,24 @@ export const SpoolShow = () => { > {t("printing.qrcode.button")} + {showNfcButton && ( + <> + + + + )} {record?.archived ? (