diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js
index bdc9c78c0..0107ea843 100644
--- a/assets/controllers/pages/barcode_scan_controller.js
+++ b/assets/controllers/pages/barcode_scan_controller.js
@@ -22,6 +22,7 @@ import {Controller} from "@hotwired/stimulus";
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
+import {decodeNdefMessage, isWebNfcAvailable, setScanInputAndSubmit} from "./nfc_helpers";
/* stimulusFetch: 'lazy' */
@@ -30,6 +31,9 @@ export default class extends Controller {
_submitting = false;
_lastDecodedText = "";
_onInfoChange = null;
+ _nfcAbortController = null;
+
+ static targets = ["reader", "nfcControls", "nfcButton", "nfcStatus"];
connect() {
@@ -63,7 +67,7 @@ export default class extends Controller {
document.getElementById("scanner-warning")?.classList.remove("d-none");
});
- this._scanner = new Html5QrcodeScanner(this.element.id, {
+ this._scanner = new Html5QrcodeScanner(this.readerTarget.id, {
fps: 10,
qrbox: qrboxFunction,
// Key change: shrink preview height on mobile
@@ -75,6 +79,10 @@ export default class extends Controller {
}, false);
this._scanner.render(this.onScanSuccess.bind(this));
+
+ if (isWebNfcAvailable() && this.hasNfcControlsTarget) {
+ this.nfcControlsTarget.classList.remove("d-none");
+ }
}
disconnect() {
@@ -83,6 +91,8 @@ export default class extends Controller {
const scanner = this._scanner;
this._scanner = null;
this._lastDecodedText = "";
+ this._nfcAbortController?.abort();
+ this._nfcAbortController = null;
// Unbind info-mode change handler (always do this, even if scanner is null)
const info = document.getElementById("scan_dialog_info_mode");
@@ -114,12 +124,41 @@ export default class extends Controller {
// Mark as handled immediately (prevents spam even if callback fires repeatedly)
this._lastDecodedText = normalized;
- const input = document.getElementById('scan_dialog_input');
- input.value = decodedText;
- //Trigger nonprintable char input controller to update the hidden input value
- input.dispatchEvent(new Event('input', { bubbles: true }));
+ setScanInputAndSubmit(decodedText);
+ }
+
+ async startNfcScan() {
+ if (!isWebNfcAvailable() || this._nfcAbortController) return;
+
+ this._nfcAbortController = new AbortController();
+ this.nfcButtonTarget.disabled = true;
+ this.nfcStatusTarget.textContent = this.nfcStatusTarget.dataset.waiting;
- //Submit form
- document.getElementById('scan_dialog_form').requestSubmit();
+ try {
+ const reader = new NDEFReader();
+ await reader.scan({signal: this._nfcAbortController.signal});
+ reader.addEventListener("readingerror", () => {
+ this.nfcStatusTarget.textContent = this.nfcStatusTarget.dataset.readError;
+ });
+ reader.addEventListener("reading", ({message}) => {
+ const value = decodeNdefMessage(message);
+ if (!value) {
+ this.nfcStatusTarget.textContent = this.nfcStatusTarget.dataset.unsupportedRecord;
+ return;
+ }
+
+ this._nfcAbortController?.abort();
+ this._nfcAbortController = null;
+ setScanInputAndSubmit(value);
+ });
+ } catch (error) {
+ if (error.name !== "AbortError") {
+ this.nfcStatusTarget.textContent = error.name === "NotAllowedError"
+ ? this.nfcStatusTarget.dataset.permissionDenied
+ : this.nfcStatusTarget.dataset.failed;
+ }
+ this._nfcAbortController = null;
+ this.nfcButtonTarget.disabled = false;
+ }
}
}
diff --git a/assets/controllers/pages/nfc_helpers.js b/assets/controllers/pages/nfc_helpers.js
new file mode 100644
index 000000000..04cb437c8
--- /dev/null
+++ b/assets/controllers/pages/nfc_helpers.js
@@ -0,0 +1,48 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+export function isWebNfcAvailable() {
+ return window.isSecureContext && "NDEFReader" in window;
+}
+
+export function decodeNdefMessage(message) {
+ for (const record of message.records) {
+ if (!["url", "absolute-url", "text"].includes(record.recordType) || !record.data) continue;
+
+ try {
+ const value = new TextDecoder(record.encoding || "utf-8").decode(record.data).trim();
+ if (value) return value;
+ } catch (_) {
+ // Ignore records with unsupported encodings and try the next record.
+ }
+ }
+
+ return null;
+}
+
+export function setScanInputAndSubmit(value) {
+ const input = document.getElementById("scan_dialog_input");
+ const form = document.getElementById("scan_dialog_form");
+ if (!input || !form) return false;
+
+ input.value = value;
+ input.dispatchEvent(new Event("input", {bubbles: true}));
+ form.requestSubmit();
+ return true;
+}
diff --git a/assets/controllers/pages/nfc_write_controller.js b/assets/controllers/pages/nfc_write_controller.js
new file mode 100644
index 000000000..bd5ad5033
--- /dev/null
+++ b/assets/controllers/pages/nfc_write_controller.js
@@ -0,0 +1,83 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {Controller} from "@hotwired/stimulus";
+import {isWebNfcAvailable} from "./nfc_helpers";
+
+/* stimulusFetch: 'lazy' */
+
+export default class extends Controller {
+ static targets = ["button", "overwriteButton", "status"];
+ static values = {url: String};
+ _abortController = null;
+
+ connect() {
+ if (isWebNfcAvailable()) this.element.classList.remove("d-none");
+ }
+
+ disconnect() {
+ this._abortController?.abort();
+ this._abortController = null;
+ }
+
+ async write(event) {
+ await this._write(event.currentTarget.dataset.overwrite === "true");
+ }
+
+ async _write(overwrite) {
+ if (this._abortController) return;
+
+ this._abortController = new AbortController();
+ this.buttonTarget.disabled = true;
+ this.overwriteButtonTarget.disabled = true;
+ this.overwriteButtonTarget.classList.add("d-none");
+ this.statusTarget.className = "small text-muted mt-2";
+ this.statusTarget.textContent = this.statusTarget.dataset.waiting;
+
+ try {
+ const writer = new NDEFReader();
+ await writer.write(
+ {records: [{recordType: "url", data: this.urlValue}]},
+ {overwrite, signal: this._abortController.signal},
+ );
+ this.statusTarget.className = "small text-success mt-2";
+ this.statusTarget.textContent = this.statusTarget.dataset.success;
+ } catch (error) {
+ await this._showError(error, overwrite);
+ } finally {
+ this._abortController = null;
+ this.buttonTarget.disabled = false;
+ }
+ }
+
+ async _showError(error, overwrite) {
+ this.statusTarget.className = "small text-danger mt-2";
+
+ if (error.name === "NotAllowedError" && !overwrite) {
+ try {
+ const permission = await navigator.permissions?.query({name: "nfc"});
+ if (permission.state === "denied") {
+ this.statusTarget.textContent = this.statusTarget.dataset.permissionDenied;
+ return;
+ }
+ } catch (_) {
+ // The NFC permission descriptor is not exposed by every supporting browser.
+ }
+
+ this.statusTarget.textContent = this.statusTarget.dataset.overwriteConfirmation;
+ this.overwriteButtonTarget.classList.remove("d-none");
+ this.overwriteButtonTarget.disabled = false;
+ return;
+ }
+
+ const messageKey = {
+ NotAllowedError: "notAllowed",
+ NotSupportedError: "unsupportedTag",
+ NetworkError: "writeFailed",
+ AbortError: "cancelled",
+ }[error.name] || "writeFailed";
+ this.statusTarget.textContent = this.statusTarget.dataset[messageKey];
+ }
+}
diff --git a/docs/usage/scanner.md b/docs/usage/scanner.md
index 47b3feff2..4774abf1b 100644
--- a/docs/usage/scanner.md
+++ b/docs/usage/scanner.md
@@ -1,10 +1,10 @@
---
-title: Barcode Scanner
+title: Barcode and NFC Scanner
layout: default
parent: Usage
---
-# Barcode scanner
+# Barcode and NFC scanner
When the user has the correct permission there will be a barcode scanner button in the navbar.
On this page you can either input a barcode code by hand, use an external barcode scanner, or use your devices camera to
@@ -49,3 +49,15 @@ of the scanned barcode, Part-DB will automatically scan the barcode that comes a
and redirects you to the corresponding page.
This allows you to quickly scan a barcode from anywhere in Part-DB without the need to first open the scanner page.
If an input field is focused, the barcode will be entered into the field as usual and no redirection will happen.
+
+## Using NFC stickers
+
+On devices with Web NFC support, the scanner page also shows a **Scan NFC tag** button. NFC access requires an NFC-capable
+Android device, a supporting browser, HTTPS, and permission from the user. Part-DB reads URL and text records from NDEF tags
+and processes their content in exactly the same way as a camera or external barcode scan. Camera and manual input remain
+available on devices without Web NFC.
+
+Users with permission to create labels can enroll a sticker for a saved part from the part's **Tools** tab. **Write NFC tag**
+writes the same Part-DB URL used by an internal QR label. The first write protects existing tag contents; if the tag is already
+programmed, Part-DB asks for confirmation and requires the tag to be tapped again before overwriting it. Tags remain writable,
+and Part-DB does not store their hardware identifiers or enrollment state.
diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php
index c4c0e5260..5044f512a 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -42,6 +42,7 @@
use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
+use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\HistoryHelper;
use App\Services\LogSystem\TimeTravel;
@@ -98,6 +99,7 @@ public function show(
DataTableFactory $dataTable,
ParameterExtractor $parameterExtractor,
PartLotWithdrawAddHelper $withdrawAddHelper,
+ BarcodeContentGenerator $barcodeContentGenerator,
?string $timestamp = null
): Response {
$this->denyAccessUnlessGranted('read', $part);
@@ -153,6 +155,9 @@ public function show(
'withdraw_add_helper' => $withdrawAddHelper,
'highlightLotId' => $request->query->getInt('highlightLot', 0),
'add_lot_form' => $addLotForm,
+ 'nfc_url' => $timeTravel_timestamp === null && $this->isGranted('@labels.create_labels')
+ ? $barcodeContentGenerator->getURLContent($part)
+ : null,
]
);
}
diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig
index 63654a85c..5cdd60159 100644
--- a/templates/label_system/scanner/scanner.html.twig
+++ b/templates/label_system/scanner/scanner.html.twig
@@ -11,7 +11,24 @@
-
+
+
+
+
+
+
+
diff --git a/templates/parts/info/_tools.html.twig b/templates/parts/info/_tools.html.twig
index 455d51b7d..d38b214dc 100644
--- a/templates/parts/info/_tools.html.twig
+++ b/templates/parts/info/_tools.html.twig
@@ -68,8 +68,36 @@
{{ dropdown.profile_dropdown('part', part.id) }}
+{% if nfc_url is not null %}
+
+
+
+
+
+{% endif %}
+
{% trans %}part.info.add_part_to_project{% endtrans %}
-
\ No newline at end of file
+
diff --git a/tests/Controller/PartControllerTest.php b/tests/Controller/PartControllerTest.php
index c15bdd518..76c1b2dc9 100644
--- a/tests/Controller/PartControllerTest.php
+++ b/tests/Controller/PartControllerTest.php
@@ -30,6 +30,7 @@
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
+use App\Entity\UserSystem\PermissionData;
use App\Entity\UserSystem\User;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use PHPUnit\Framework\Attributes\Group;
@@ -57,6 +58,28 @@ public function testShowPart(): void
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertSelectorExists('[data-controller~="pages--nfc-write"]');
+ $this->assertSelectorExists('[data-pages--nfc-write-url-value$="/scan/part/' . $part->getId() . '"]');
+ }
+
+ public function testShowPartDoesNotOfferNfcWritingWithoutLabelPermission(): void
+ {
+ $client = static::createClient();
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $part = $entityManager->getRepository(Part::class)->find(1);
+
+ if (!$user || !$part) {
+ $this->markTestSkipped('Required test fixtures not found');
+ }
+
+ $user->getPermissions()->setPermissionValue('labels', 'create_labels', PermissionData::DISALLOW);
+ $client->loginUser($user);
+ $client->request('GET', '/en/part/' . $part->getId());
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $this->assertSelectorNotExists('[data-controller~="pages--nfc-write"]');
}
public function testShowPartWithTimestamp(): void
diff --git a/tests/Controller/ScanControllerTest.php b/tests/Controller/ScanControllerTest.php
index b504cd292..9e3466fef 100644
--- a/tests/Controller/ScanControllerTest.php
+++ b/tests/Controller/ScanControllerTest.php
@@ -51,4 +51,13 @@ public function testScanQRCode(): void
$this->client->request('GET', '/scan/part/1');
$this->assertResponseRedirects('/en/part/1');
}
+
+ public function testScanDialogContainsProgressiveNfcControls(): void
+ {
+ $this->client->request('GET', '/en/scan');
+
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorExists('[data-pages--barcode-scan-target~="nfcControls"]');
+ $this->assertSelectorTextContains('[data-pages--barcode-scan-target~="nfcControls"]', 'Scan NFC tag');
+ }
}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index fa3998b54..b5f20aada 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -921,6 +921,42 @@ Sub elements will be moved upwards.
Select source
+
+
+ label_scanner.nfc.scan
+ Scan NFC tag
+
+
+
+
+ label_scanner.nfc.waiting
+ Hold an NFC tag near your device…
+
+
+
+
+ label_scanner.nfc.read_error
+ The NFC tag could not be read. Try again.
+
+
+
+
+ label_scanner.nfc.unsupported_record
+ The tag contains no supported URL or text record.
+
+
+
+
+ label_scanner.nfc.permission_denied
+ NFC permission was denied.
+
+
+
+
+ label_scanner.nfc.failed
+ NFC scanning could not be started. Check that NFC is enabled.
+
+ log.list.title
@@ -7140,6 +7176,66 @@ Element 1 -> Element 1.2
Add this [part] to a [project]
+
+
+ part.info.nfc.write.button
+ Write NFC tag
+
+
+
+
+ part.info.nfc.write.overwrite_button
+ Retry and allow overwrite
+
+
+
+
+ part.info.nfc.write.waiting
+ Hold the sticker near your device until writing completes…
+
+
+
+
+ part.info.nfc.write.success
+ The NFC sticker was enrolled successfully.
+
+
+
+
+ part.info.nfc.write.overwrite_confirmation
+ Writing without overwriting was not allowed. The tag may already contain data. Continuing may replace its contents.
+
+
+
+
+ part.info.nfc.write.permission_denied
+ NFC permission was denied.
+
+
+
+
+ part.info.nfc.write.not_allowed
+ NFC writing was not allowed. Check browser permission and whether the tag is writable.
+
+
+
+
+ part.info.nfc.write.unsupported_tag
+ This tag is read-only or does not support writable NDEF data.
+
+
+
+
+ part.info.nfc.write.failed
+ The tag could not be written. Check its capacity and try again.
+
+
+
+
+ part.info.nfc.write.cancelled
+ NFC writing was cancelled.
+
+ project_bom_entry.label