diff --git a/assets/controllers/pages/handheld_scan_controller.js b/assets/controllers/pages/handheld_scan_controller.js
new file mode 100644
index 000000000..81aa54790
--- /dev/null
+++ b/assets/controllers/pages/handheld_scan_controller.js
@@ -0,0 +1,135 @@
+import {Controller} from "@hotwired/stimulus";
+/**
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2024 Alex Barclay (https://github.com/barclaac)
+ *
+ * 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 .
+ */
+
+/* stimulusFetch: 'lazy' */
+export default class extends Controller {
+
+ _scanner = null;
+
+ constructor() {
+ super()
+ _scanner = new ScannerSerial()
+ }
+
+ connect(event) {
+ console.log('Controller connected')
+
+ if (_scanner.isConnected()) {
+ console.log('already connected')
+ document.getElementById('handheld_scanner_dialog_connect').style.display='none'
+ document.getElementById('handheld_scanner_dialog_disconnect').style.display=''
+ }
+ }
+
+ async onConnectScanner(event) {
+ console.log('Connect to barcode reader')
+
+ if (!_scanner.isConnected()) {
+ await _scanner.connect(async (reader) => {
+ decoder = new TextDecoder();
+ var barcodeBuffer = '';
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) {
+ console.log('releasing reader')
+ reader.releaseLock();
+ reader = null;
+ break;
+ }
+ console.log(value);
+ console.log(decoder.decode(value));
+ partial = decoder.decode(value);
+ barcodeBuffer += partial
+ end = false
+ endidx = partial.indexOf('\x1e\x04');
+ if (endidx != -1) {
+ end = true;
+ } else {
+ endidx = partial.indexOf('\r');
+ if (endidx != -1) {
+ end = true;
+ }
+ }
+
+ if (end) {
+ // Decode the barcode
+ console.log(barcodeBuffer)
+ start = barcodeBuffer.indexOf('[)>')
+ if (start == -1) {
+ console.log('badly formed barcode')
+ } else {
+ // Post this back to the server
+ document.getElementById('handheld_scanner_dialog_barcode').value = barcodeBuffer;
+ form = document.getElementById('handheld_dialog_form');
+ form.requestSubmit();
+ }
+ barcodeBuffer = '';
+ }
+ }
+ })
+ document.getElementById('handheld_scanner_dialog_connect').style.display='none'
+ document.getElementById('handheld_scanner_dialog_disconnect').style.display=''
+
+ }
+ }
+
+ async onDisconnectScanner(event) {
+ console.log('Disconnect called')
+ if (_scanner.isConnected) {
+ _scanner.disconnect()
+ document.getElementById('handheld_scanner_dialog_connect').style.display=''
+ document.getElementById('handheld_scanner_dialog_disconnect').style.display='none'
+ }
+ }
+}
+
+class ScannerSerial {
+ device = null
+ reader = null
+
+ constructor() {
+ if (ScannerSerial.instance) {
+ return ScannerSerial.instance;
+ }
+ ScannerSerial.instance = this;
+ }
+
+ async connect(readHandler) {
+ this.device = await navigator.serial.requestPort({ filters: [{ usbVendorId: 0x03f0, usbDeviceId: 0x0339 }] })
+ await this.device.open({baudRate: 9600})
+
+ console.log(this.device)
+ this.reader = this.device.readable.getReader()
+ readHandler(this.reader)
+ }
+
+ async disconnect() {
+ await this.reader.cancel()
+ this.reader = null
+ this.device.close()
+ }
+
+ isConnected() {
+ if (this.reader == null) {
+ return false;
+ }
+ return true
+ }
+}
diff --git a/docs/usage/eigp114_barcode_operations.md b/docs/usage/eigp114_barcode_operations.md
new file mode 100644
index 000000000..1f135ead0
--- /dev/null
+++ b/docs/usage/eigp114_barcode_operations.md
@@ -0,0 +1,28 @@
+# EIGP 114 Barcode Operations #
+
+## Concept of Operation ##
+Module is intended to be as quick and easy as possible once you receive your bag of goodies from
+Digikey or Mouser. Steps would be:
+ * Grab a bag from the shipment
+ * Identify the component type and scan your intended storage box
+ * Scan the storage container
+ * Scan the bag and put in container
+ * Grab the next bag (same component type)
+ * Scan the bag and put in container
+ * Grab the next bag (different component type)
+ * Scan new container
+ * Scan the bag and put in container
+
+### Alternatives ###
+
+ * Grab a bag from the shipment
+ * Scan the bag
+ * Put the bag in the single storage location indicated
+ * Scan next bag from shipment...
+
+## Scanner Types ##
+
+Currently only supports an HP 4430 scanner. Code uses WebSerial to communicate with the scanner. For
+this to me more widespread would need to add a database of scanner USB device IDs and some data
+indicating how each is different
+
diff --git a/src/Controller/HandheldScannerController.php b/src/Controller/HandheldScannerController.php
new file mode 100644
index 000000000..3e1ab1b2f
--- /dev/null
+++ b/src/Controller/HandheldScannerController.php
@@ -0,0 +1,437 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\LabelSystem\LabelOptions;
+use App\Entity\LabelSystem\LabelProcessMode;
+use App\Entity\LabelSystem\LabelProfile;
+use App\Entity\LabelSystem\LabelSupportedElement;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+use App\Exceptions\TwigModeException;
+use App\Form\LabelSystem\HandheldScannerDialogType;
+use App\Helpers\EIGP114;
+use App\Repository\DBElementRepository;
+use App\Services\ElementTypeNameGenerator;
+use App\Services\LabelSystem\LabelGenerator;
+use App\Services\Misc\RangeParser;
+use App\Services\Parts\PartLotWithdrawAddHelper;
+use Doctrine\ORM\EntityManagerInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Form\Form;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormError;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class BarcodeScanType
+{
+ protected ?string $barcode;
+ protected ?string $manufacturerPN;
+ protected ?int $quantity;
+ protected ?string $location;
+
+ // Show last item and quantity added to help check if scanning is working well
+ protected ?string $lastManufacturerPN;
+ protected ?int $lastQuantity;
+
+ public function __construct() {
+ $this->barcode = "";
+ $this->manufacturerPN = "";
+ $this->quantity = 0;
+ $this->location = "";
+
+ $this->lastManufacturerPN = "";
+ $this->lastQuantity = 0;
+ }
+
+ public function getBarcode(): ?string {
+ return $this->barcode;
+ }
+
+ public function setBarcode(?string $barcode): self {
+ $this->barcode = $barcode;
+ return $this;
+ }
+
+ public function getManufacturerPN(): ?string {
+ return $this->manufacturerPN;
+ }
+
+ public function setManufacturerPN(?string $manufacturerPN): self {
+ $this->manufacturerPN = $manufacturerPN;
+ return $this;
+ }
+
+ public function getLastManufacturerPN(): ?string {
+ return $this->lastManufacturerPN;
+ }
+
+ public function setLastManufacturerPN(?string $lastManufacturerPN): self {
+ $this->lastManufacturerPN = $lastManufacturerPN;
+ return $this;
+ }
+
+ public function getQuantity(): ?int {
+ return $this->quantity;
+ }
+
+ public function setQuantity(?int $quantity): self {
+ $this->quantity = $quantity;
+ return $this;
+ }
+
+ public function getLastQuantity(): ?int {
+ return $this->lastQuantity;
+ }
+
+ public function setLastQuantity(?int $lastQuantity): self {
+ $this->lastQuantity = $lastQuantity;
+ return $this;
+ }
+
+ public function getLocation(): ?string {
+ return $this->location;
+ }
+
+ public function setLocation(?string $location): self {
+ $this->location = $location;
+ return $this;
+ }
+
+ public function cycleLastAdded() : void {
+ $this->lastManufacturerPN = $this->manufacturerPN;
+ $this->manufacturerPN = null;
+ $this->lastQuantity = $this->quantity;
+ $this->quantity = 0;
+ }
+}
+
+class HandheldScannerController extends AbstractController
+{
+ public function __construct(protected EntityManagerInterface $em, protected LoggerInterface $logger, protected TranslatorInterface $translator)
+ {
+ }
+
+ #[Route(path: '/handheldscanner',name: 'handheld_scanner_dialog')]
+ public function generator(Request $request,
+ PartLotWithdrawAddHelper $withdrawAddHelper): Response
+ {
+ $this->logger->info('*** rendering form ***');
+ $this->logger->info(var_export($request->getPayload()->all(), true));
+
+ $barcode = new BarcodeScanType();
+ $form = $this->buildForm($barcode);
+
+ $form->handleRequest($request);
+
+ if ($form->get('autocommit')->getData() == true || ($form->isSubmitted() && $form->isValid())) {
+ if ($this->processSubmit($form, $withdrawAddHelper, $barcode)) {
+ // Need a new form to render because we can't change submitted form
+ $this->logger->info('replacing form with fresh');
+ $barcode->cycleLastAdded(); // Shuffle into last added slot
+ $newForm = $this->buildForm($barcode);
+ $newForm->get('missingloc')->setData($form->get('missingloc')->getData());
+ $newForm->get('locfrompart')->setData($form->get('locfrompart')->getData());
+ $newForm->get('foundloc')->setData($form->get('foundloc')->getData());
+ $newForm->get('missingpart')->setData($form->get('missingpart')->getData());
+ $newForm->get('manufacturer_pn')->setData('');
+ $newForm->get('quantity')->setData(0);
+ $form = $newForm;
+ }
+ }
+ return $this->render('label_system/handheld_scanner/handheld_scanner.html.twig', [
+ 'form' => $form,
+ ]);
+ }
+
+ protected function buildForm(BarcodeScanType $barcode) : FormInterface
+ {
+ $builder = $this->container->get('form.factory')
+ ->createBuilder(HandheldScannerDialogType::class, $barcode);
+ $this->addPreSubmitEventHandler($builder);
+
+ $form = $builder->getForm();
+
+ return $form;
+ }
+
+ protected function addPreSubmitEventHandler(FormBuilderInterface $builder)
+ {
+ $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
+ $form = $event->getForm();
+ $data = $event->getData();
+ if (!isset($data['barcode'])) {
+ return;
+ }
+ $r = EIGP114::decode($data['barcode']);
+
+ // Remove barcode so that if user has edited fields that the barcode won't
+ // override any of the user's values
+ unset($data['barcode']);
+
+ if (array_key_exists('location', $r)) {
+ $data['location'] = $r['location'];
+ $data['last_scan'] = 'location';
+ $data['manufacturer_pn'] = '';
+ $data['locfrompart'] = false;
+ $data['foundpart'] = false;
+ $data['quantity'] = '';
+ } else if (array_key_exists('supplier_pn', $r)) {
+ $data['manufacturer_pn'] = $r['supplier_pn'];
+ $data['last_scan'] = 'part';
+ if (array_key_exists('quantity', $r)) {
+ $data['quantity'] = $r['quantity'];
+ }
+ }
+
+ // Look up the location in the database to see if one needs to be created
+ if ($data['location'] != "") {
+ $storageRepository = $this->em->getRepository(StorageLocation::class);
+ $storage = $this->getStorageLocation($data['location']);
+ $data['foundloc'] = ($storage != null);
+ }
+
+ // Look up the part in the database to see if one needs to be created
+ $part = null;
+ if ($data['manufacturer_pn'] != "") {
+ $partRepository = $this->em->getRepository(Part::class);
+ $part = $partRepository->findOneBy(['manufacturer_product_number' => $data['manufacturer_pn']]);
+ $data['foundpart'] = ($part != null);
+ if ($data['foundpart'] == false &&
+ (array_key_exists('missingpart', $data) && $data['missingpart'] == false) &&
+ $data['autocommit'] == true) {
+ $this->addFlash('error', 'Cannot autocommit part - part not in database');
+ }
+ }
+
+ // Did we want to use the storage location for this part
+ // Will require all part-lots to be in the same location
+ if (array_key_exists('locfrompart', $data) && $part) {
+ $this->logger->info('take loc from part');
+ $locs=[];
+ foreach ($part->getPartLots() as &$pl) {
+ $locs[$pl->getStorageLocation()->getId()] = $pl->getStorageLocation();
+ }
+ if (count($locs) == 1) {
+ // Got exactly 1 location - can set this as the default
+ $storageLoc = array_pop($locs);
+ $data['location'] = $storageLoc->getName();
+ }
+ }
+
+ $event->setData($data);
+ });
+ }
+
+ protected function processSubmit(FormInterface $form, PartLotWithdrawAddHelper $withdrawAddHelper,
+ BarcodeScanType $barcode) : bool {
+ $this->logger->info("form submitted");
+ // We could be here through an autosubmit or because the actual button was pressed
+ // To proceed we need a storage location, manufacturer part number and a quantity
+ $this->logger->info('processSubmit');
+ if ($form instanceof Form && $form->getClickedButton() != null || $form->get('autocommit')->getData() == true) {
+ $fail = false;
+ if ($barcode->getLocation() != '' && $barcode->getManufacturerPN() != '' &&
+ $barcode->getQuantity() != 0) {
+ // Got all the data that we need - now work through the items to see if we
+ // can submit the data
+ $storageLocation = $this->getStorageLocation($barcode->getLocation());
+ if (!$storageLocation) {
+ if ($form->get('missingloc')->getData() == true) {
+ $storageLocation = $this->createStorageLocation($barcode->getLocation());
+ } else {
+ $fail = true;
+ $this->addFlash('error', 'storage doesn\'t exist');
+ }
+ }
+
+ $part = $this->getPart($barcode->getManufacturerPN());
+ if (!$part) {
+ $this->logger->debug('part not found');
+ if ($form->get('missingpart')->getData() == true) {
+ $this->logger->debug('create part');
+ $part = $this->createMissingPart($barcode->getManufacturerPN());
+ $this->logger->debug('part created');
+
+ $this->em->flush();
+ } else {
+ $fail = true;
+ $this->addFlash('error', 'part doesn\'t exist');
+ }
+ }
+
+ if (!$fail) {
+ // Have a part and storage location so attempt to add stock for this combination
+ $found=false;
+ $partLots = $part->getPartLots();
+ if ($partLots != null) {
+ foreach ($part->getPartLots() as &$pl) {
+ if ($pl->getStorageLocation()->getId() == $storageLocation->getId()) {
+ $this->logger->info('Found existing storage location, adding stock');
+ if ($withdrawAddHelper->canAdd($pl)) {
+ $withdrawAddHelper->add($pl, $barcode->getQuantity(), "Barcode scan add");
+ $found = true;
+ }
+ break;
+ }
+ $this->logger->info('Part lot {fullPath}', ['fullPath' => $pl->getStorageLocation()->getFullPath()]);
+ }
+ }
+ if (!$found) {
+ // No part lot for this storage location - add one
+ $partLot = new PartLot();
+ $partLot->setStorageLocation($storageLocation);
+ $partLot->setInstockUnknown(false);
+ $partLot->setAmount(0.0);
+ $part->addPartLot($partLot);
+ $this->em->flush(); // Must have an ID for target
+ if ($withdrawAddHelper->canAdd($partLot)) {
+ $withdrawAddHelper->add($partLot, $barcode->getQuantity(), "Barcode scan add");
+ }
+
+ }
+ }
+
+ if (!$fail) {
+ $this->em->flush();
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getStorageLocation(string $name) : ?StorageLocation
+ {
+ $repository = $this->em->getRepository(StorageLocation::class);
+ $storage = $repository->findOneBy(['name' => $name]);
+ if ($storage) {
+ $this->logger->info($storage->getFullPath());
+ } else {
+ $this->logger->info('Storage not found in database');
+ }
+ if ($storage instanceof StorageLocation) {
+ return $storage;
+ }
+ return null;
+ }
+
+ protected function createStorageLocation(string $location): StorageLocation
+ {
+ $repository = $this->em->getRepository(StorageLocation::class);
+ $storage = new StorageLocation();
+ $storage->setName($location);
+ $this->em->persist($storage);
+ return $storage;
+ }
+
+ protected function getPart(string $partNumber) : ?Part
+ {
+ $repository = $this->em->getRepository(Part::class);
+ $part = $repository->findOneBy(['manufacturer_product_number' => $partNumber]);
+ if ($part) {
+ $this->logger->info($part->getName());
+ }
+ return $part;
+ }
+
+ protected function createMissingPart(string $partNumber) : ?Part
+ {
+ $repository = $this->em->getRepository(Category::class);
+ $category = $repository->findOneBy(['name' => 'Unclassified']);
+
+ $part = new Part();
+ if ($category instanceof Category) {
+ $part->setCategory($category);
+ }
+ $part->setName($partNumber);
+ $part->setManufacturerProductNumber($partNumber);
+ $this->em->persist($part);
+
+ return $part;
+ }
+
+ protected function addStock(Form $form, PartLotWithdrawAddHelper $withdrawAddHelper,
+ BarcodeScanType $barcode)
+ {
+ $storage = null;
+ $part = null;
+ // See if the storage location exists was in barcode
+ if ($barcode->getLocation() != "") {
+ $storage = $this->getStorageLocation($barcode->getLocation());
+ }
+ if ($barcode->getManufacturerPN() != "") {
+ // Got a part instead
+ $repository = $this->em->getRepository(Part::class);
+ $part = $repository->findOneBy(['manufacturer_product_number' => $barcode->getManufacturerPN()]);
+ if ($part) {
+ $this->logger->info($part->getName());
+ }
+ }
+
+ // Does a part lot exist for this combination?
+ if ($storage != null && $part != null) {
+ $found=false;
+ foreach ($part->getPartLots() as &$pl) {
+ if ($pl->getStorageLocation()->getId() == $storage->getId()) {
+ $this->logger->info('Found existing storage location, adding stock');
+ if ($withdrawAddHelper->canAdd($pl)) {
+ $withdrawAddHelper->add($pl, $barcode->getQuantity(), "Test add");
+ $found = true;
+ }
+ $this->em->flush();
+ $this->addFlash('success', 'stock added');
+ break;
+ }
+ $this->logger->info('Part lot {fullPath}', ['fullPath' => $pl->getStorageLocation()->getFullPath()]);
+ }
+ if (!$found) {
+ // No part lot for this storage location - add one
+ $partLot = new PartLot();
+ $partLot->setStorageLocation($storage);
+ $partLot->setInstockUnknown(false);
+ $partLot->setAmount(0.0);
+ $part->addPartLot($partLot);
+ $this->em->flush();
+ if ($withdrawAddHelper->canAdd($partLot)) {
+ $withdrawAddHelper->add($partLot, $barcode->getQuantity(), "Creational add");
+ }
+
+ $this->em->flush();
+ $this->addFlash('success', 'partlot added');
+ }
+ }
+ }
+}
diff --git a/src/Form/LabelSystem/HandheldScannerDialogType.php b/src/Form/LabelSystem/HandheldScannerDialogType.php
new file mode 100644
index 000000000..7afb5679c
--- /dev/null
+++ b/src/Form/LabelSystem/HandheldScannerDialogType.php
@@ -0,0 +1,139 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Form\LabelSystem;
+
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Form\LabelOptionsType;
+use App\Helpers\EIGP114;
+use App\Validator\Constraints\Misc\ValidRange;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ButtonType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\Extension\Core\Type\IntegerType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class HandheldScannerDialogType extends AbstractType
+{
+ public function __construct(protected Security $security)
+ {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options = []): void
+ {
+ $builder->add('barcode', HiddenType::Class, [
+ 'required' => true,
+ 'action' => '',
+ ]);
+ $builder->add('location', TextType::Class, [
+ 'required' => false,
+ 'label' => 'Storage Location',
+ 'help' => 'Scan this first, will erase part fields',
+ ]);
+
+ $builder->add('manufacturer_pn', TextType::Class, [
+ 'required' => false,
+ 'label' => 'Manufacturer Part',
+ ]);
+
+ $builder->add('last_manufacturer_pn', TextType::Class, [
+ 'required' => false,
+ 'label' => 'Last Added Manufacturer Part',
+ ]);
+
+ $builder->add('quantity', IntegerType::Class, [
+ 'required' => false,
+ 'label' => 'Quantity',
+ ]);
+
+ $builder->add('last_quantity', IntegerType::Class, [
+ 'required' => false,
+ 'label' => 'Last Added Quantity',
+ ]);
+
+ $builder->add('missingloc', CheckboxType::Class, [
+ 'label' => 'Create missing Storage Location',
+ 'mapped' => false,
+ 'required' => false,
+ ]);
+
+ $builder->add('locfrompart', CheckboxType::Class, [
+ 'label' => 'Take storage location from part',
+ 'mapped' => false,
+ 'required' => false,
+ ]);
+
+ $builder->add('foundloc', CheckboxType::Class, [
+ 'label' => 'Storage Location in database',
+ 'mapped' => false,
+ 'required' => false,
+ ]);
+
+ $builder->add('missingpart', CheckboxType::Class, [
+ 'label' => 'Create missing Part',
+ 'mapped' => false,
+ 'required' => false,
+ ]);
+ $builder->add('foundpart', CheckboxType::Class, [
+ 'label' => 'Part in database',
+ 'mapped' => false,
+ 'required' => false,
+ ]);
+
+ $builder->add('autocommit', CheckboxType::Class, [
+ 'label' => 'Autocommit on Scan',
+ 'mapped' => false,
+ 'required' => false,
+ ]);
+
+ $builder->add('connect', ButtonType::Class, [
+ 'label' => 'Connect',
+ 'attr' => ['data-action' => 'pages--handheld-scan#onConnectScanner',
+ 'class' => 'btn btn-primary' ],
+ ]);
+
+ $builder->add('disconnect', ButtonType::Class, [
+ 'label' => 'Disconnect',
+ 'attr' => ['data-action' => 'pages--handheld-scan#onDisconnectScanner',
+ 'class' => 'btn btn-primary',
+ 'style' => 'display: none'],
+ ]);
+
+ $builder->add('submit', SubmitType::Class, [
+ 'label' => 'Submit',
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefault('mapped', false);
+ $resolver->setDefault('allow_extra_fields', true);
+ }
+}
diff --git a/src/Helpers/EIGP114.php b/src/Helpers/EIGP114.php
new file mode 100644
index 000000000..5933815da
--- /dev/null
+++ b/src/Helpers/EIGP114.php
@@ -0,0 +1,85 @@
+.
+ */
+declare(strict_types=1);
+
+namespace App\Helpers;
+
+use Psr\Log\LoggerInterface;
+
+/*
+ EIGP 114
+ ISO/IEC 15418
+ ANS MH10.8.2-2016
+
+ Based on the ISO standard but it seems that both Mouser and Digikey aren't quite
+ following the standard.
+
+ Message is defined as (very loose RE format): ({:RS:} = 0x1e, {:GS:} = 0x1d, {:EOT:} = 0x04)
+ Header: [)>{:RS:}06{:GS:}
+ Field data identifier: [0-9]*[A-Z]
+ Field data: .*
+ Field Separator: {:GS:} - may or may not be present after last field
+ Trailer: {:RS:}{:EOT:}
+
+ Mouser & Digikey differences:
+ Header: [)>06{:GS:} - note the missing {:RS:}
+ Field: last field missing trailer {:RS:}{:EOT:} so use {:CR:} inserted by scanner itself
+
+ */
+class EIGP114
+{
+ public static function decode($barcode): array {
+ $rslt = [];
+
+ // Find the start sequence for a type 6 ISO/IEC 15434 message
+ $hdrPattern = "/(\[\)>\u{001e}*06\u{001d})([[:print:]\u{001d}]*)(\u{001e}\u{0004})*/";
+ $matches = [];
+ $loc = preg_match_all($hdrPattern, $barcode, $matches);
+ $fields = [];
+ if ($loc) {
+ $remain = $matches[2][0];
+ $fields = preg_split("/\u{001d}/", $remain);
+ foreach ($fields as &$v) {
+ EIGP114::processField($v, $rslt);
+ }
+ }
+ return $rslt;
+ }
+
+ private static function processField($field, &$result) : void {
+ $pattern = "/([0-9]{0,2}[A-Z])(.+)/";
+ $matches = [];
+ if (preg_match_all($pattern, $field, $matches)) {
+ switch ($matches[1][0]) {
+ case 'L':
+ $result['location'] = $matches[2][0];
+ break;
+ case '1P':
+ $result['supplier_pn'] = $matches[2][0];
+ break;
+ case 'Q':
+ $result['quantity'] = $matches[2][0];
+ break;
+ default:
+ //array_push($result, $matches);
+ }
+ }
+ }
+}
diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php
index 185713060..fb26cc596 100644
--- a/src/Services/Trees/ToolsTreeBuilder.php
+++ b/src/Services/Trees/ToolsTreeBuilder.php
@@ -140,6 +140,13 @@ protected function getToolsNode(): array
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
}
+ if ($this->security->isGranted('@info_providers.create_parts')) {
+ $nodes[] = (new TreeViewNode(
+ "Handheld Scanner",
+ $this->urlGenerator->generate('handheld_scanner_dialog')
+ ))->setIcon('fa-treeview fa-fw fa-solid fa-camera-retro');
+ }
+
return $nodes;
}
diff --git a/templates/label_system/handheld_scanner/handheld_scanner.html.twig b/templates/label_system/handheld_scanner/handheld_scanner.html.twig
new file mode 100644
index 000000000..90c66aa32
--- /dev/null
+++ b/templates/label_system/handheld_scanner/handheld_scanner.html.twig
@@ -0,0 +1,30 @@
+{% extends 'main_card.html.twig' %}
+
+{% block card_title %}Handheld Scanner Operations{% endblock %}
+
+{% block card_content %}
+{{ form_start(form, {'attr': {'id': 'handheld_dialog_form'}}) }}
+
+{{ form_row(form.location) }}
+{{ form_row(form.missingloc) }}
+{{ form_row(form.locfrompart) }}
+{{ form_row(form.foundloc) }}
+{{ form_row(form.manufacturer_pn) }}
+{{ form_row(form.missingpart) }}
+{{ form_row(form.foundpart) }}
+{{ form_row(form.quantity) }}
+{{ form_row(form.autocommit) }}
+
+
+{{ form_row(form.last_manufacturer_pn) }}
+{{ form_row(form.last_quantity) }}
+
+
+
+ {{ form_widget(form.connect) }}
+ {{ form_widget(form.disconnect) }}
+ {{ form_widget(form.submit) }}
+
+
+{{ form_end(form) }}
+{% endblock %}