├── inventree_brother ├── __init__.py └── brother_plugin.py ├── setup.cfg ├── pyproject.toml ├── .pre-commit-config.yaml ├── .github ├── release.yml └── workflows │ ├── pep.yaml │ └── pypi.yaml ├── LICENSE ├── setup.py ├── .gitignore └── README.md /inventree_brother/__init__.py: -------------------------------------------------------------------------------- 1 | BROTHER_PLUGIN_VERSION = "2.0.5" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | # - W293 - blank lines contain whitespace 4 | W293, 5 | # - E501 - line too long (82 characters) 6 | E501 7 | N802 8 | exclude = .git,__pycache__,dist,build,test.py 9 | max-complexity = 20 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "twine"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.ruff] 6 | exclude = [ 7 | ".git", 8 | "__pycache__", 9 | "test.py", 10 | "tests", 11 | "venv", 12 | "env", 13 | ".venv", 14 | ".env", 15 | ] 16 | 17 | src=["inventree_brother"] 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.12.0 5 | hooks: 6 | - id: ruff-format 7 | args: [ --preview ] 8 | - id: ruff 9 | args: [ 10 | --fix, 11 | # --unsafe-fixes, 12 | --preview 13 | ] 14 | - repo: https://github.com/biomejs/pre-commit 15 | rev: v2.0.0-beta.5 16 | hooks: 17 | - id: biome-check 18 | additional_dependencies: ["@biomejs/biome@2.0.0"] 19 | files: ^frontend/src.*\.(js|ts|tsx)$ 20 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # .github/release.yml 2 | 3 | changelog: 4 | categories: 5 | - title: Breaking Changes 6 | labels: 7 | - Semver-Major 8 | - breaking 9 | - title: New Features 10 | labels: 11 | - Semver-Minor 12 | - enhancement 13 | - title: Bug Fixes 14 | labels: 15 | - Semver-Patch 16 | - bug 17 | - title: Devops / Setup Changes 18 | labels: 19 | - docker 20 | - setup 21 | - demo 22 | - CI 23 | - security 24 | - title: Other Changes 25 | labels: 26 | - "*" 27 | -------------------------------------------------------------------------------- /.github/workflows/pep.yaml: -------------------------------------------------------------------------------- 1 | name: Style Checks 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | python-version: [3.9] 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install Deps 22 | run: | 23 | pip install -U ruff 24 | pip install -U wheel setuptools twine build 25 | - name: Style Checks 26 | run: | 27 | ruff check 28 | - name: Build Binary 29 | run: | 30 | python -m build 31 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | # Publish to PyPi package index 2 | 3 | name: PIP Publish 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | 11 | publish: 12 | name: Publish to PyPi 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Code 17 | uses: actions/checkout@v2 18 | - name: Setup Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.9 22 | - name: Install Deps 23 | run: | 24 | pip install -U wheel setuptools twine build 25 | - name: Build Binary 26 | run: | 27 | python -m build 28 | - name: Publish 29 | run: | 30 | python3 -m twine upload dist/* 31 | env: 32 | TWINE_USERNAME: __token__ 33 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 34 | TWINE_REPOSITORY: pypi 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 InvenTree 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import importlib 4 | import importlib.util 5 | import os 6 | import setuptools 7 | 8 | """Read the plugin version from the source code.""" 9 | module_path = os.path.join( 10 | os.path.dirname(__file__), "inventree_brother", "__init__.py" 11 | ) 12 | spec = importlib.util.spec_from_file_location("inventree_brother", module_path) 13 | inventree_brother = importlib.util.module_from_spec(spec) 14 | spec.loader.exec_module(inventree_brother) 15 | 16 | with open("README.md", encoding="utf-8") as f: 17 | long_description = f.read() 18 | 19 | 20 | setuptools.setup( 21 | name="inventree-brother-plugin", 22 | version=inventree_brother.BROTHER_PLUGIN_VERSION, 23 | author="Oliver Walters", 24 | author_email="oliver.henry.walters@gmail.com", 25 | description="Brother label printer plugin for InvenTree", 26 | long_description=long_description, 27 | long_description_content_type="text/markdown", 28 | keywords="inventree label printer printing inventory", 29 | url="https://github.com/inventree/inventree-brother-plugin", 30 | license="MIT", 31 | packages=setuptools.find_packages(), 32 | install_requires=[ 33 | "brother-ql-inventree>=1.1", 34 | ], 35 | setup_requires=[ 36 | "wheel", 37 | "twine", 38 | ], 39 | python_requires=">=3.9", 40 | entry_points={ 41 | "inventree_plugins": [ 42 | "BrotherLabeLPlugin = inventree_brother.brother_plugin:BrotherLabelPlugin" 43 | ] 44 | }, 45 | classifiers=[ 46 | "Programming Language :: Python :: 3", 47 | "Operating System :: OS Independent", 48 | "Framework :: InvenTree", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | .vscode/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 2 | ![PEP](https://github.com/inventree/inventree-python/actions/workflows/pep.yaml/badge.svg) 3 | 4 | 5 | # inventree-brother-plugin 6 | 7 | A label printing plugin for [InvenTree](https://inventree.org), which provides support for the [Brother label printers](https://www.brother.com.au/en/products/all-labellers/labellers). 8 | 9 | This plugin supports printing to *some* Brother label printers with network (wired or WiFi) support. Refer to the [brother_ql docs](https://github.com/pklaus/brother_ql/blob/master/brother_ql/models.py) for a list of label printers which are directly supported. 10 | 11 | ## Installation 12 | 13 | ### Minimum Requirements 14 | 15 | > [!IMPORTANT] 16 | > This plugin now requires the "modern" InvenTree UI - version `0.18.0` or newer. The plugin will not function correctly on an InvenTree instance below version `0.18.0` 17 | 18 | ### Installation Procedure 19 | 20 | Install this plugin manually as follows: 21 | 22 | ``` 23 | pip install inventree-brother-plugin 24 | ``` 25 | 26 | Or, add to your `plugins.txt` file to install automatically using the `invoke install` command: 27 | 28 | ``` 29 | inventree-brother-plugin 30 | ``` 31 | 32 | Now open your InvenTree's "Admin Center > Plugins" page to activate the plugin. Next, read below for instructions on setting up a printer via "Admin Center > Machines". 33 | 34 | ## Setup a machine instance for a Brother Label Printer 35 | 36 | This plugin provides a driver for the machine registry in InvenTree, where multiple instances of this driver can 37 | be set up for each physical label printer you want to connect to. Each machine has its own individual configuration set. 38 | 39 | To set up a new machine, go to "Admin Center > Machines" and hit the "+" button. Now choose a name for this specific printer, select "Label Printer" as machine type and "Brother Label Printer Driver" as a driver, then submit. The new printer will now be listed in the machines table. To configure the printer, click on its line to open the "Machine detail" panel where you can set the "Driver Settings" to match your label printer. 40 | 41 | ## Configuration Options 42 | The following list gives an overview of the available settings. Also check out the `brother-ql` package for more information. 43 | 44 | * **Printer Model** 45 | Currently supported models are: 46 | QL-500, QL-550, QL-560, QL-570, QL-580N, QL-600, QL-650TD, QL-700, QL-710W, QL-720NW, QL-800, QL-810W, QL-820NWB, QL-1050, QL-1060N, QL-1100, QL-1100NWB, QL-1115NWB, PT-P750W, PT-P900W, PT-P950NW 47 | 48 | * **Label Media** 49 | Size and type of the label media. Supported options are (not all labels are available on all printers): 50 | 12, 18, 29, 38, 50, 54, 62, 62red, 102, 103, 104, 17x54, 17x87, 23x23, 29x42, 29x90, 39x90, 39x48, 52x29, 54x29, 60x86, 62x29, 62x100, 102x51, 102x152, 103x164, d12, d24, d58, pt12, pt18, pt24, pt36 51 | 52 | * **IP Address** 53 | If connected via TCP/IP, specify the IP address here. 54 | 55 | * **USB Device** 56 | If connected via USB, specify the device identifier here (VENDOR_ID:PRODUCT_ID/SERIAL_NUMBER, e.g. from `lsusb`). 57 | 58 | * **Auto Cut** 59 | Cut label after printing. 60 | 61 | * **Rotation** 62 | Rotation angle, either 0, 90, 180 or 270 degrees. 63 | 64 | * **Compression** 65 | Set image compression (required for some printers). 66 | 67 | * **High Quality** 68 | Print in high quality (required for some printers). 69 | -------------------------------------------------------------------------------- /inventree_brother/brother_plugin.py: -------------------------------------------------------------------------------- 1 | """Brother label printing plugin for InvenTree. 2 | 3 | Supports direct printing of labels to networked label printers, using the brother_ql library. 4 | """ 5 | 6 | # Required brother_ql libs 7 | from brother_ql.conversion import convert 8 | from brother_ql.raster import BrotherQLRaster 9 | from brother_ql.backends.helpers import send 10 | from brother_ql.models import ALL_MODELS 11 | from brother_ql.labels import ALL_LABELS, FormFactor 12 | 13 | from django.db import models 14 | from django.utils.translation import gettext_lazy as _ 15 | 16 | from . import BROTHER_PLUGIN_VERSION 17 | 18 | # InvenTree plugin libs 19 | from report.models import LabelTemplate 20 | from plugin import InvenTreePlugin 21 | from plugin.machine import BaseMachineType 22 | from plugin.machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine 23 | 24 | # Image library 25 | from PIL import ImageOps 26 | 27 | # Backwards compatibility imports 28 | try: 29 | from plugin.mixins import MachineDriverMixin 30 | except ImportError: 31 | 32 | class MachineDriverMixin: 33 | """Dummy mixin for backwards compatibility.""" 34 | 35 | pass 36 | 37 | 38 | class BrotherLabelPlugin(MachineDriverMixin, InvenTreePlugin): 39 | """Brother label printer driver plugin for InvenTree.""" 40 | 41 | AUTHOR = "Oliver Walters" 42 | DESCRIPTION = "Label printing plugin for Brother printers" 43 | VERSION = BROTHER_PLUGIN_VERSION 44 | 45 | # Machine registry was added in InvenTree 0.14.0, use inventree-brother-plugin 0.9.0 for older versions 46 | # Machine driver interface was fixed with 0.16.0 to work inside of inventree workers 47 | MIN_VERSION = "0.16.0" 48 | 49 | NAME = "Brother Labels" 50 | SLUG = "brother" 51 | TITLE = "Brother Label Printer" 52 | 53 | # Use background printing 54 | BLOCKING_PRINT = False 55 | 56 | def get_machine_drivers(self) -> list: 57 | """Register machine drivers.""" 58 | return [BrotherLabelPrinterDriver] 59 | 60 | 61 | class BrotherLabelPrinterDriver(LabelPrinterBaseDriver): 62 | """Brother label printing driver for InvenTree.""" 63 | 64 | SLUG = "brother" 65 | NAME = "Brother Label Printer Driver" 66 | DESCRIPTION = "Brother label printing driver for InvenTree" 67 | 68 | def __init__(self, *args, **kwargs): 69 | """Initialize the BrotherLabelPrinterDriver.""" 70 | self.MACHINE_SETTINGS = { 71 | "MODEL": { 72 | "name": _("Printer Model"), 73 | "description": _("Select model of Brother printer"), 74 | "choices": self.get_model_choices, 75 | "default": "PT-P750W", 76 | "required": True, 77 | }, 78 | "LABEL": { 79 | "name": _("Label Media"), 80 | "description": _("Select label media type"), 81 | "choices": self.get_label_choices, 82 | "default": "12", 83 | "required": True, 84 | }, 85 | "IP_ADDRESS": { 86 | "name": _("IP Address"), 87 | "description": _("IP address of the brother label printer"), 88 | "default": "", 89 | }, 90 | "USB_DEVICE": { 91 | "name": _("USB Device"), 92 | "description": _( 93 | "USB device identifier of the label printer (VID:PID/SERIAL)" 94 | ), 95 | "default": "", 96 | }, 97 | "AUTO_CUT": { 98 | "name": _("Auto Cut"), 99 | "description": _("Cut each label after printing"), 100 | "validator": bool, 101 | "default": True, 102 | "required": True, 103 | }, 104 | "ROTATION": { 105 | "name": _("Rotation"), 106 | "description": _("Rotation of the image on the label"), 107 | "choices": self.get_rotation_choices, 108 | "default": "0", 109 | "required": True, 110 | }, 111 | "COMPRESSION": { 112 | "name": _("Compression"), 113 | "description": _( 114 | "Enable image compression option (required for some printer models)" 115 | ), 116 | "validator": bool, 117 | "default": False, 118 | "required": True, 119 | }, 120 | "HQ": { 121 | "name": _("High Quality"), 122 | "description": _( 123 | "Enable high quality option (required for some printers)" 124 | ), 125 | "validator": bool, 126 | "default": True, 127 | "required": True, 128 | }, 129 | } 130 | 131 | super().__init__(*args, **kwargs) 132 | 133 | def get_model_choices(self, **kwargs): 134 | """Returns a list of available printer models""" 135 | return [(model.name, model.name) for model in ALL_MODELS] 136 | 137 | def get_label_choices(self, **kwargs): 138 | """Return a list of available label types""" 139 | return [(label.identifier, label.name) for label in ALL_LABELS] 140 | 141 | def get_rotation_choices(self, **kwargs): 142 | """Return a list of available rotation angles""" 143 | return [(f"{degree}", f"{degree}°") for degree in [0, 90, 180, 270]] 144 | 145 | def init_machine(self, machine: BaseMachineType): 146 | """Machine initialize hook.""" 147 | # static dummy setting for now, should probably be actively checked for USB printers 148 | # and maybe by running a simple ping test or similar for networked printers 149 | machine.set_status(LabelPrinterMachine.MACHINE_STATUS.CONNECTED) 150 | 151 | def print_label( 152 | self, 153 | machine: LabelPrinterMachine, 154 | label: LabelTemplate, 155 | item: models.Model, 156 | **kwargs, 157 | ) -> None: 158 | """Send the label to the printer""" 159 | 160 | # TODO: Add padding around the provided image, otherwise the label does not print correctly 161 | # ^ Why? The wording in the underlying brother_ql library ('dots_printable') seems to suggest 162 | # at least that area is fully printable. 163 | # TODO: Improve label auto-scaling based on provided width and height information 164 | 165 | # Extract width (x) and height (y) information 166 | # width = kwargs['width'] 167 | # height = kwargs['height'] 168 | # ^ currently this width and height are those of the label template (before conversion to PDF 169 | # and PNG) and are of little use 170 | 171 | # Printing options requires a modern-ish InvenTree backend, 172 | # which supports the 'printing_options' keyword argument 173 | options = kwargs.get("printing_options", {}) 174 | n_copies = int(options.get("copies", 1)) 175 | 176 | label_image = self.render_to_png(label, item) 177 | 178 | # Read settings 179 | model = machine.get_setting("MODEL", "D") 180 | ip_address = machine.get_setting("IP_ADDRESS", "D") 181 | usb_device = machine.get_setting("USB_DEVICE", "D") 182 | media_type = machine.get_setting("LABEL", "D") 183 | 184 | # Get specifications of media type 185 | media_specs = None 186 | for label_specs in ALL_LABELS: 187 | if label_specs.identifier == media_type: 188 | media_specs = label_specs 189 | 190 | rotation = int(machine.get_setting("ROTATION", "D")) + 90 191 | rotation = rotation % 360 192 | 193 | if rotation in [90, 180, 270]: 194 | label_image = label_image.rotate(rotation, expand=True) 195 | 196 | try: 197 | # Resize image if media type is a die cut label (the brother_ql library only accepts images 198 | # with a specific size in that case) 199 | # TODO: Make it generic for all media types 200 | # TODO: Add GUI settings to configure scaling and margins 201 | if media_specs.form_factor in [ 202 | FormFactor.DIE_CUT, 203 | FormFactor.ROUND_DIE_CUT, 204 | ]: 205 | # Scale image to fit the entire printable area and pad with whitespace (while preserving aspect ratio) 206 | printable_image = ImageOps.pad( 207 | label_image, media_specs.dots_printable, color="white" 208 | ) 209 | 210 | else: 211 | # Just leave image as-is 212 | printable_image = label_image 213 | except AttributeError as e: 214 | raise AttributeError( 215 | "Could not find specifications of label media type '%s'" % media_type 216 | ) from e 217 | except Exception as e: 218 | raise e 219 | 220 | # Check if red labels used 221 | if media_type in ["62red"]: 222 | red = True 223 | else: 224 | red = False 225 | 226 | printer = BrotherQLRaster(model=model) 227 | 228 | # Generate instructions for printing 229 | params = { 230 | "qlr": printer, 231 | "images": [printable_image], 232 | "label": media_type, 233 | "cut": machine.get_setting("AUTO_CUT", "D"), 234 | "rotate": 0, 235 | "compress": machine.get_setting("COMPRESSION", "D"), 236 | "hq": machine.get_setting("HQ", "D"), 237 | "red": red, 238 | } 239 | 240 | instructions = convert(**params) 241 | 242 | # Select appropriate identifier and backend 243 | printer_id = "" 244 | backend_id = "" 245 | 246 | # check IP address first, then USB 247 | if ip_address: 248 | printer_id = f"tcp://{ip_address}" 249 | backend_id = "network" 250 | elif usb_device: 251 | printer_id = f"usb://{usb_device}" 252 | backend_id = "pyusb" 253 | else: 254 | # Raise error when no backend is defined 255 | raise ValueError("No IP address or USB device defined.") 256 | 257 | for _i in range(n_copies): 258 | send( 259 | instructions=instructions, 260 | printer_identifier=printer_id, 261 | backend_identifier=backend_id, 262 | blocking=True, 263 | ) 264 | --------------------------------------------------------------------------------