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