From eb135aee392a3d37f70101e796b9c7064ce8eae0 Mon Sep 17 00:00:00 2001 From: Marc Ole Bulling Date: Fri, 18 Jun 2021 20:45:42 +0200 Subject: [PATCH] Add support for printing shoppinglist with thermal printer (#1273) * Added escpos-php library * Added button to shoppinglist print menu * Added to translation * Added basic printing logic and API call * Working implementation for printing with the API * Added openapi json * Correctly parsing boolean parameter * Working button in UI * Change to grocy formatting * Add Date * Only show thermal print button when Feature Flag is set * Fixed API call and added error message parsing * Undo translation * Add flag to print quantities as well * Added printing notes * Added quantity conversion * Increse feed * Fixed that checkbox was undefined, as dialog was already closed * Added padding * Formatting * Added note about user permission * Fixed error when using notes instead of products * Review - Default FEATURE_FLAG_THERMAL_PRINTER to disabled - Added missing localization strings (and slightly adjusted one) * Fixed merge conflicts Co-authored-by: Bernd Bestel --- composer.json | 3 +- config-dist.php | 17 ++++ controllers/BaseController.php | 7 ++ controllers/PrintApiController.php | 41 +++++++++ controllers/StockApiController.php | 1 - controllers/StockController.php | 1 + grocy.openapi.json | 60 ++++++++++++ helpers/PrerequisiteChecker.php | 2 +- localization/strings.pot | 13 +++ public/viewjs/shoppinglist.js | 142 +++++++++++++++++++---------- routes.php | 3 + services/BaseService.php | 6 ++ services/PrintService.php | 84 +++++++++++++++++ services/StockService.php | 82 +++++++++++++++++ 14 files changed, 413 insertions(+), 49 deletions(-) create mode 100644 controllers/PrintApiController.php create mode 100644 services/PrintService.php diff --git a/composer.json b/composer.json index 5313a982..dbe94366 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "gumlet/php-image-resize": "^1.9", "ezyang/htmlpurifier": "^4.13", "jucksearm/php-barcode": "^1.0", - "guzzlehttp/guzzle": "^7.0" + "guzzlehttp/guzzle": "^7.0", + "mike42/escpos-php": "^3.0" }, "autoload": { "psr-4": { diff --git a/config-dist.php b/config-dist.php index aa653045..a39cc028 100644 --- a/config-dist.php +++ b/config-dist.php @@ -95,6 +95,22 @@ Setting('MEAL_PLAN_FIRST_DAY_OF_WEEK', ''); // see the file controllers/Users/User.php for possible values Setting('DEFAULT_PERMISSIONS', ['ADMIN']); +// When using a thermal printer (thermal printers are receipt printers, not regular printers) +// The printer must support the ESC/POS protocol, see https://github.com/mike42/escpos-php +Setting('TPRINTER_IS_NETWORK_PRINTER', false); // Set to true if it is a network printer +Setting('TPRINTER_PRINT_QUANTITY_NAME', true); // Set to false if you do not want to print the quantity names +Setting('TPRINTER_PRINT_NOTES', true); // Set to false if you do not want to print notes + +//Configuration below for network printers. If you are using a USB/serial printer, skip to next section +Setting('TPRINTER_IP', '127.0.0.1'); // IP of the network printer +Setting('TPRINTER_PORT', 9100); // Port of printer, eg. 9100 +//Configuration below if you are using a USB or serial printer +Setting('TPRINTER_CONNECTOR', '/dev/usb/lp0'); // Location of printer. For USB on Linux this is often '/dev/usb/lp0', + // for serial printers it could be similar to '/dev/ttyS0' + // Make sure that the user that runs the webserver has permissions to write to the printer! + // On Linux add your webserver user to the LP group with usermod -a -G lp www-data + + // Default user settings // These settings can be changed per user, here the defaults // are defined which are used when the user has not changed the setting so far @@ -198,6 +214,7 @@ Setting('FEATURE_FLAG_STOCK_PRODUCT_FREEZING', true); Setting('FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_FIELD_NUMBER_PAD', true); // Activate the number pad in due date fields on (supported) mobile browsers Setting('FEATURE_FLAG_SHOPPINGLIST_MULTIPLE_LISTS', true); Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true); +Setting('FEATURE_FLAG_THERMAL_PRINTER', false); // Feature settings Setting('FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT', true); // When set to true, opened items will be counted as missing for calculating if a product is below its minimum stock amount diff --git a/controllers/BaseController.php b/controllers/BaseController.php index 0ca463a0..0a773501 100644 --- a/controllers/BaseController.php +++ b/controllers/BaseController.php @@ -11,6 +11,7 @@ use Grocy\Services\ChoresService; use Grocy\Services\DatabaseService; use Grocy\Services\FilesService; use Grocy\Services\LocalizationService; +use Grocy\Services\PrintService; use Grocy\Services\RecipesService; use Grocy\Services\SessionService; use Grocy\Services\StockService; @@ -93,6 +94,12 @@ class BaseController return StockService::getInstance(); } + protected function getPrintService() + { + return PrintService::getInstance(); + } + + protected function getTasksService() { return TasksService::getInstance(); diff --git a/controllers/PrintApiController.php b/controllers/PrintApiController.php new file mode 100644 index 00000000..7664300e --- /dev/null +++ b/controllers/PrintApiController.php @@ -0,0 +1,41 @@ +getQueryParams(); + + $listId = 1; + if (isset($params['list'])) { + $listId = $params['list']; + } + + $printHeader = true; + if (isset($params['printHeader'])) { + $printHeader = ($params['printHeader'] === "true"); + } + $items = $this->getStockService()->GetShoppinglistInPrintableStrings($listId); + return $this->ApiResponse($response, $this->getPrintService()->printShoppingList($printHeader, $items)); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + + public function __construct(\DI\Container $container) + { + parent::__construct($container); + } +} diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index e25c1144..c76fd24a 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -1,5 +1,4 @@ getUsersService(); diff --git a/grocy.openapi.json b/grocy.openapi.json index a5ce4512..72a36ee9 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -52,6 +52,9 @@ }, { "name": "Files" + }, + { + "name": "Print" } ], "paths": { @@ -4030,7 +4033,64 @@ } } } + }, + "/print/shoppinglist/thermal": { + "get": { + "summary": "Prints the shoppinglist with a thermal printer", + "tags": [ + "Print" + ], + "parameters": [ + { + "in": "query", + "name": "list", + "required": false, + "description": "Shopping list id", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "printHeader", + "required": false, + "description": "Prints grocy logo if true", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Returns OK if the printing was successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "The operation was not successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error400" + } + } + } + } } + } + } }, "components": { "internalSchemas": { diff --git a/helpers/PrerequisiteChecker.php b/helpers/PrerequisiteChecker.php index 7c0b628b..37f6b370 100644 --- a/helpers/PrerequisiteChecker.php +++ b/helpers/PrerequisiteChecker.php @@ -4,7 +4,7 @@ class ERequirementNotMet extends Exception { } -const REQUIRED_PHP_EXTENSIONS = ['fileinfo', 'pdo_sqlite', 'gd', 'ctype']; +const REQUIRED_PHP_EXTENSIONS = ['fileinfo', 'pdo_sqlite', 'gd', 'ctype', 'json', 'intl', 'zlib']; const REQUIRED_SQLITE_VERSION = '3.9.0'; class PrerequisiteChecker diff --git a/localization/strings.pot b/localization/strings.pot index 9847421a..40cb5fa4 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2129,3 +2129,16 @@ msgstr "" msgid "Open stock entry print label in new window" msgstr "" + +msgid "Thermal printer" +msgstr "" + +msgid "Printing" +msgstr "" + +msgid "Connecting to printer..." +msgstr "" + +msgid "Unable to print" +msgstr "" + diff --git a/public/viewjs/shoppinglist.js b/public/viewjs/shoppinglist.js index cbc2d9c2..bc472167 100644 --- a/public/viewjs/shoppinglist.js +++ b/public/viewjs/shoppinglist.js @@ -1,4 +1,4 @@ -var shoppingListTable = $('#shoppinglist-table').DataTable({ +var shoppingListTable = $('#shoppinglist-table').DataTable({ 'order': [[1, 'asc']], "orderFixed": [[3, 'asc']], 'columnDefs': [ @@ -428,56 +428,106 @@ $(document).on("click", "#print-shopping-list-button", function(e) \ '; + var sizePrintDialog = 'medium'; + var printButtons = { + cancel: { + label: __t('Cancel'), + className: 'btn-secondary', + callback: function() + { + bootbox.hideAll(); + } + }, + printtp: { + label: __t('Thermal printer'), + className: 'btn-secondary', + callback: function() + { + bootbox.hideAll(); + var printHeader = $("#print-show-header").prop("checked"); + var thermalPrintDialog = bootbox.dialog({ + title: __t('Printing'), + message: '

' + __t('Connecting to printer...') + '

' + }); + //Delaying for one second so that the alert can be closed + setTimeout(function() + { + Grocy.Api.Get('print/shoppinglist/thermal?list=' + $("#selected-shopping-list").val() + '&printHeader=' + printHeader, + function(result) + { + bootbox.hideAll(); + }, + function(xhr) + { + console.error(xhr); + var validResponse = true; + try + { + var jsonError = JSON.parse(xhr.responseText); + } catch (e) + { + validResponse = false; + } + if (validResponse) + { + thermalPrintDialog.find('.bootbox-body').html(__t('Unable to print') + '
' + jsonError.error_message + '
'); + } else + { + thermalPrintDialog.find('.bootbox-body').html(__t('Unable to print') + '
' + xhr.responseText + '
'); + } + } + ); + }, 1000); + } + }, + ok: { + label: __t('Print'), + className: 'btn-primary responsive-button', + callback: function() + { + bootbox.hideAll(); + $('.modal-backdrop').remove(); + $(".print-timestamp").text(moment().format("l LT")); + + $("#description-for-print").html($("#description").val()); + if ($("#description").text().isEmpty()) + { + $("#description-for-print").parent().addClass("d-print-none"); + } + + if (!$("#print-show-header").prop("checked")) + { + $("#print-header").addClass("d-none"); + } + + if (!$("#print-group-by-product-group").prop("checked")) + { + shoppingListPrintShadowTable.rowGroup().enable(false); + shoppingListPrintShadowTable.order.fixed({}); + shoppingListPrintShadowTable.draw(); + } + + $(".print-layout-container").addClass("d-none"); + $("." + $("input[name='print-layout-type']:checked").val()).removeClass("d-none"); + + window.print(); + } + } + } + + if (!Grocy.FeatureFlags["GROCY_FEATURE_FLAG_THERMAL_PRINTER"]) + { + delete printButtons['printtp']; + sizePrintDialog = 'small'; + } + bootbox.dialog({ message: dialogHtml, - size: 'small', + size: sizePrintDialog, backdrop: true, closeButton: false, className: "d-print-none", - buttons: { - cancel: { - label: __t('Cancel'), - className: 'btn-secondary', - callback: function() - { - bootbox.hideAll(); - } - }, - ok: { - label: __t('Print'), - className: 'btn-primary responsive-button', - callback: function() - { - bootbox.hideAll(); - $('.modal-backdrop').remove(); - - $(".print-timestamp").text(moment().format("l LT")); - - $("#description-for-print").html($("#description").val()); - if ($("#description").text().isEmpty()) - { - $("#description-for-print").parent().addClass("d-print-none"); - } - - if (!$("#print-show-header").prop("checked")) - { - $("#print-header").addClass("d-none"); - } - - if (!$("#print-group-by-product-group").prop("checked")) - { - shoppingListPrintShadowTable.rowGroup().enable(false); - shoppingListPrintShadowTable.order.fixed({}); - shoppingListPrintShadowTable.draw(); - } - - $(".print-layout-container").addClass("d-none"); - $("." + $("input[name='print-layout-type']:checked").val()).removeClass("d-none"); - - window.print(); - } - } - } + buttons: printButtons }); }); diff --git a/routes.php b/routes.php index be0b6f98..8268958a 100644 --- a/routes.php +++ b/routes.php @@ -232,6 +232,9 @@ $app->group('/api', function (RouteCollectorProxy $group) { $group->post('/chores/executions/{executionId}/undo', '\Grocy\Controllers\ChoresApiController:UndoChoreExecution'); $group->post('/chores/executions/calculate-next-assignments', '\Grocy\Controllers\ChoresApiController:CalculateNextExecutionAssignments'); + //Printing + $group->get('/print/shoppinglist/thermal', '\Grocy\Controllers\PrintApiController:PrintShoppingListThermal'); + // Batteries $group->get('/batteries', '\Grocy\Controllers\BatteriesApiController:Current'); $group->get('/batteries/{batteryId}', '\Grocy\Controllers\BatteriesApiController:BatteryDetails'); diff --git a/services/BaseService.php b/services/BaseService.php index 86a4a5d4..ef04ec81 100644 --- a/services/BaseService.php +++ b/services/BaseService.php @@ -66,4 +66,10 @@ class BaseService { return UsersService::getInstance(); } + + protected function getPrintService() + { + return PrintService::getInstance(); + } + } diff --git a/services/PrintService.php b/services/PrintService.php new file mode 100644 index 00000000..b3b1a826 --- /dev/null +++ b/services/PrintService.php @@ -0,0 +1,84 @@ +format('d/m/Y H:i'); + + $printer->setJustification(Printer::JUSTIFY_CENTER); + $printer->selectPrintMode(Printer::MODE_DOUBLE_WIDTH); + $printer->setTextSize(4, 4); + $printer->setReverseColors(true); + $printer->text("grocy"); + $printer->setJustification(); + $printer->setTextSize(1, 1); + $printer->setReverseColors(false); + $printer->feed(2); + $printer->text($dateFormatted); + $printer->selectPrintMode(); + $printer->feed(2); + } + + /** + * @param bool $printHeader Printing of Grocy logo + * @param string[] $lines Items to print + * @return string[] Returns array with result OK if no exception + * @throws Exception If unable to print, an exception is thrown + */ + public function printShoppingList(bool $printHeader, array $lines): array + { + $printer = self::getPrinterHandle(); + if ($printer === false) + throw new Exception("Unable to connect to printer"); + + if ($printHeader) + { + self::printHeader($printer); + } + + foreach ($lines as $line) + { + $printer->text($line); + $printer->feed(); + } + + $printer->feed(3); + $printer->cut(); + $printer->close(); + return [ + 'result' => "OK" + ]; + } +} diff --git a/services/StockService.php b/services/StockService.php index 2bb41f6f..423b27bf 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -970,6 +970,88 @@ class StockService extends BaseService } } + /** + * Returns the shoppinglist as an array with lines for a printer + * @param int $listId ID of shopping list + * @return string[] Returns an array in the format "[amount] [name of product]" + * @throws \Exception + */ + public function GetShoppinglistInPrintableStrings($listId = 1): array + { + if (!$this->ShoppingListExists($listId)) + { + throw new \Exception('Shopping list does not exist'); + } + + $result_product = array(); + $result_quantity = array(); + $rowsShoppingListProducts = $this->getDatabase()->uihelper_shopping_list()->where('shopping_list_id = :1', $listId)->fetchAll(); + foreach ($rowsShoppingListProducts as $row) + { + $isValidProduct = ($row->product_id != null && $row->product_id != ""); + if ($isValidProduct) + { + $product = $this->getDatabase()->products()->where('id = :1', $row->product_id)->fetch(); + $conversion = $this->getDatabase()->quantity_unit_conversions_resolved()->where('product_id = :1 AND from_qu_id = :2 AND to_qu_id = :3', $product->id, $product->qu_id_stock, $row->qu_id)->fetch(); + $factor = 1.0; + if ($conversion != null) + { + $factor = floatval($conversion->factor); + } + $amount = round($row->amount * $factor); + $note = ""; + if (GROCY_TPRINTER_PRINT_NOTES) + { + if ($row->note != "") { + $note = ' (' . $row->note . ')'; + } + } + } + if (GROCY_TPRINTER_PRINT_QUANTITY_NAME && $isValidProduct) + { + $quantityname = $row->qu_name; + if ($amount > 1) + { + $quantityname = $row->qu_name_plural; + } + array_push($result_quantity, $amount . ' ' . $quantityname); + array_push($result_product, $row->product_name . $note); + } + else + { + if ($isValidProduct) + { + array_push($result_quantity, $amount); + array_push($result_product, $row->product_name . $note); + } + else + { + array_push($result_quantity, round($row->amount)); + array_push($result_product, $row->note); + } + + } + } + //Add padding to look nicer + $maxlength = 1; + foreach ($result_quantity as $quantity) + { + if (strlen($quantity) > $maxlength) + { + $maxlength = strlen($quantity); + } + } + $result = array(); + $length = count($result_quantity); + for ($i = 0; $i < $length; $i++) + { + $quantity = str_pad($result_quantity[$i], $maxlength); + array_push($result, $quantity . ' ' . $result_product[$i]); + } + return $result; + } + + public function TransferProduct(int $productId, float $amount, int $locationIdFrom, int $locationIdTo, $specificStockEntryId = 'default', &$transactionId = null) { if (!$this->ProductExists($productId))