Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions assets/controllers/pages/barcode_scan_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' */

Expand All @@ -30,6 +31,9 @@ export default class extends Controller {
_submitting = false;
_lastDecodedText = "";
_onInfoChange = null;
_nfcAbortController = null;

static targets = ["reader", "nfcControls", "nfcButton", "nfcStatus"];

connect() {

Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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");
Expand Down Expand Up @@ -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;
}
}
}
48 changes: 48 additions & 0 deletions assets/controllers/pages/nfc_helpers.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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;
}
83 changes: 83 additions & 0 deletions assets/controllers/pages/nfc_write_controller.js
Original file line number Diff line number Diff line change
@@ -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];
}
}
16 changes: 14 additions & 2 deletions docs/usage/scanner.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
5 changes: 5 additions & 0 deletions src/Controller/PartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,6 +99,7 @@ public function show(
DataTableFactory $dataTable,
ParameterExtractor $parameterExtractor,
PartLotWithdrawAddHelper $withdrawAddHelper,
BarcodeContentGenerator $barcodeContentGenerator,
?string $timestamp = null
): Response {
$this->denyAccessUnlessGranted('read', $part);
Expand Down Expand Up @@ -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,
]
);
}
Expand Down
19 changes: 18 additions & 1 deletion templates/label_system/scanner/scanner.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,24 @@
<div class="form-group row">
<div class="{{ offset_label }} {{ col_input }}">
<div class="img-thumbnail" style="max-width: 600px;">
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
<div {{ stimulus_controller('pages/barcode_scan') }}>
<div class="d-none mb-3" {{ stimulus_target('pages/barcode_scan', 'nfcControls') }}>
<button type="button" class="btn btn-outline-primary"
{{ stimulus_target('pages/barcode_scan', 'nfcButton') }}
{{ stimulus_action('pages/barcode_scan', 'startNfcScan') }}>
<i class="fa-solid fa-wifi fa-rotate-90 fa-fw"></i>
{% trans %}label_scanner.nfc.scan{% endtrans %}
</button>
<div class="small text-muted mt-2" role="status" aria-live="polite"
{{ stimulus_target('pages/barcode_scan', 'nfcStatus') }}
data-waiting="{{ 'label_scanner.nfc.waiting'|trans }}"
data-read-error="{{ 'label_scanner.nfc.read_error'|trans }}"
data-unsupported-record="{{ 'label_scanner.nfc.unsupported_record'|trans }}"
data-permission-denied="{{ 'label_scanner.nfc.permission_denied'|trans }}"
data-failed="{{ 'label_scanner.nfc.failed'|trans }}"></div>
</div>
<div id="reader-box" {{ stimulus_target('pages/barcode_scan', 'reader') }}></div>
</div>
</div>
</div>
</div>
Expand Down
30 changes: 29 additions & 1 deletion templates/parts/info/_tools.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,36 @@

{{ dropdown.profile_dropdown('part', part.id) }}

{% if nfc_url is not null %}
<div class="d-none mt-2" {{ stimulus_controller('pages/nfc_write', {url: nfc_url}) }}>
<button type="button" class="btn btn-secondary"
data-overwrite="false"
{{ stimulus_target('pages/nfc_write', 'button') }}
{{ stimulus_action('pages/nfc_write', 'write') }}>
<i class="fa-solid fa-wifi fa-rotate-90 fa-fw"></i>
{% trans %}part.info.nfc.write.button{% endtrans %}
</button>
<button type="button" class="btn btn-danger d-none"
data-overwrite="true"
{{ stimulus_target('pages/nfc_write', 'overwriteButton') }}
{{ stimulus_action('pages/nfc_write', 'write') }}>
{% trans %}part.info.nfc.write.overwrite_button{% endtrans %}
</button>
<div class="small text-muted mt-2" role="status" aria-live="polite"
{{ stimulus_target('pages/nfc_write', 'status') }}
data-waiting="{{ 'part.info.nfc.write.waiting'|trans }}"
data-success="{{ 'part.info.nfc.write.success'|trans }}"
data-overwrite-confirmation="{{ 'part.info.nfc.write.overwrite_confirmation'|trans }}"
data-permission-denied="{{ 'part.info.nfc.write.permission_denied'|trans }}"
data-not-allowed="{{ 'part.info.nfc.write.not_allowed'|trans }}"
data-unsupported-tag="{{ 'part.info.nfc.write.unsupported_tag'|trans }}"
data-write-failed="{{ 'part.info.nfc.write.failed'|trans }}"
data-cancelled="{{ 'part.info.nfc.write.cancelled'|trans }}"></div>
</div>
{% endif %}

<a class="btn btn-success mt-2" {% if not is_granted('@projects.edit') %}disabled{% endif %}
href="{{ path('project_add_parts_no_id', {"parts": part.id, "_redirect": uri_without_host(app.request)}) }}">
<i class="fa-solid fa-magnifying-glass-plus fa-fw"></i>
{% trans %}part.info.add_part_to_project{% endtrans %}
</a>
</a>
23 changes: 23 additions & 0 deletions tests/Controller/PartControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions tests/Controller/ScanControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Loading