diff --git a/brother_ql/backends/helpers.py b/brother_ql/backends/helpers.py index 279221c..46f38f2 100755 --- a/brother_ql/backends/helpers.py +++ b/brother_ql/backends/helpers.py @@ -15,11 +15,12 @@ logger = logging.getLogger(__name__) def discover(backend_identifier='linux_kernel'): - + if backend_identifier is None: + logger.info("Backend for discovery not specified, defaulting to linux_kernel.") + backend_identifier = "linux_kernel" be = backend_factory(backend_identifier) list_available_devices = be['list_available_devices'] BrotherQLBackend = be['backend_class'] - available_devices = list_available_devices() return available_devices @@ -101,3 +102,50 @@ def send(instructions, printer_identifier=None, backend_identifier=None, blockin logger.info("Printing was successful. Waiting for the next job.") return status + + +def status( + printer_identifier=None, + backend_identifier=None, +): + """ + Retrieve status info from the printer, including model and currently loaded media size. + + :param str printer_identifier: Identifier for the printer. + :param str backend_identifier: Can enforce the use of a specific backend. + """ + + selected_backend = None + if backend_identifier: + selected_backend = backend_identifier + else: + try: + selected_backend = guess_backend(printer_identifier) + except ValueError: + logger.info("No backend stated. Selecting the default linux_kernel backend.") + selected_backend = "linux_kernel" + if selected_backend == "network": + # Not implemented due to lack of an available test device + raise NotImplementedError + + be = backend_factory(selected_backend) + BrotherQLBackend = be["backend_class"] + printer = BrotherQLBackend(printer_identifier) + + logger.info("Sending status information request to the printer.") + printer.write(b"\x1b\x69\x53") # "ESC i S" Status information request + data = printer.read() + try: + result = interpret_response(data) + except ValueError: + logger.error("Failed to parse response data: %s", data) + + logger.info(f"Printer Series Code: 0x{result['series_code']:02x}") + logger.info(f"Printer Model Code: 0x{result['model_code']:02x}") + logger.info(f"Printer Status Type: {result['status_type']} ") + logger.info(f"Printer Phase Type: {result['phase_type']})") + logger.info(f"Printer Errors: {result['errors']}") + logger.info(f"Media Type: {result['media_type']}") + logger.info(f"Media Size: {result['media_width']} x {result['media_length']} mm") + + return result diff --git a/brother_ql/backends/pyusb.py b/brother_ql/backends/pyusb.py index 183e596..7be2749 100755 --- a/brother_ql/backends/pyusb.py +++ b/brother_ql/backends/pyusb.py @@ -21,7 +21,7 @@ def list_available_devices(): returns: devices: a list of dictionaries with the keys 'identifier' and 'instance': \ [ {'identifier': 'usb://0x04f9:0x2015/C5Z315686', 'instance': pyusb.core.Device()}, ] - The 'identifier' is of the format idVendor:idProduct_iSerialNumber. + The 'identifier' is of the format idVendor:idProduct/iSerialNumber. """ class find_class(object): @@ -44,8 +44,10 @@ def __call__(self, device): def identifier(dev): try: - serial = usb.util.get_string(dev, 256, dev.iSerialNumber) - return 'usb://0x{:04x}:0x{:04x}_{}'.format(dev.idVendor, dev.idProduct, serial) + serial = usb.util.get_string(dev, dev.iSerialNumber) + return "usb://0x{:04x}:0x{:04x}/{}".format( + dev.idVendor, dev.idProduct, serial + ) except: return 'usb://0x{:04x}:0x{:04x}'.format(dev.idVendor, dev.idProduct) diff --git a/brother_ql/cli.py b/brother_ql/cli.py index f573f8c..77cc934 100755 --- a/brother_ql/cli.py +++ b/brother_ql/cli.py @@ -2,12 +2,15 @@ # Python standard library import logging +import os +from urllib.parse import urlparse # external dependencies import click # imports from this very package from brother_ql.devicedependent import models, label_sizes, label_type_specs, DIE_CUT_LABEL, ENDLESS_LABEL, ROUND_DIE_CUT_LABEL +from brother_ql.models import ModelsManager from brother_ql.backends import available_backends, backend_factory @@ -43,7 +46,48 @@ def cli(ctx, *args, **kwargs): def discover(ctx): """ find connected label printers """ backend = ctx.meta.get('BACKEND', 'pyusb') - discover_and_list_available_devices(backend) + if backend is None: + logger.info("Defaulting to pyusb as backend for discovery.") + backend = "pyusb" + from brother_ql.backends.helpers import discover, status + + available_devices = discover(backend_identifier=backend) + for device in available_devices: + device_status = None + result = {"model": "unknown"} + + # skip network discovery since it's not supported + if backend == "pyusb" or backend == "linux_kernel": + logger.info(f"Probing device at {device['identifier']}") + + # check permissions before accessing lp* devices + if backend == "linux_kernel": + url = urlparse(device["identifier"]) + if not os.access(url.path, os.W_OK): + logger.info( + f"Cannot access device {device['identifier']} due to insufficient permissions. You need to be a part of the lp group to access printers with this backend." + ) + continue + + # send status request + device_status = status( + printer_identifier=device["identifier"], + backend_identifier=backend, + ) + + # look up series code and model code + for m in ModelsManager().iter_elements(): + if ( + device_status["series_code"] == m.series_code + and device_status["model_code"] == m.model_code + ): + result = {"model": m.identifier} + break + + result.update(device) + logger.info( + "Found a label printer at: {identifier} (model: {model})".format(**result), + ) def discover_and_list_available_devices(backend): from brother_ql.backends.helpers import discover @@ -120,7 +164,7 @@ def env(ctx, *args, **kwargs): @cli.command('print', short_help='Print a label') @click.argument('images', nargs=-1, type=click.File('rb'), metavar='IMAGE [IMAGE] ...') -@click.option('-l', '--label', type=click.Choice(label_sizes), envvar='BROTHER_QL_LABEL', help='The label (size, type - die-cut or endless). Run `brother_ql info labels` for a full list including ideal pixel dimensions.') +@click.option('-l', '--label', required=True, type=click.Choice(label_sizes), envvar='BROTHER_QL_LABEL', help='The label (size, type - die-cut or endless). Run `brother_ql info labels` for a full list including ideal pixel dimensions.') @click.option('-r', '--rotate', type=click.Choice(('auto', '0', '90', '180', '270')), default='auto', help='Rotate the image (counterclock-wise) by this amount of degrees.') @click.option('-t', '--threshold', type=float, default=70.0, help='The threshold value (in percent) to discriminate between black and white pixels.') @click.option('-d', '--dither', is_flag=True, help='Enable dithering when converting the image to b/w. If set, --threshold is meaningless.') @@ -162,5 +206,17 @@ def send_cmd(ctx, *args, **kwargs): from brother_ql.backends.helpers import send send(instructions=kwargs['instructions'].read(), printer_identifier=ctx.meta.get('PRINTER'), backend_identifier=ctx.meta.get('BACKEND'), blocking=True) + +@cli.command(name="status", short_help="query printer status and the loaded media size") +@click.pass_context +def status_cmd(ctx, *args, **kwargs): + from brother_ql.backends.helpers import status + + status( + printer_identifier=ctx.meta.get("PRINTER"), + backend_identifier=ctx.meta.get("BACKEND"), + ) + + if __name__ == '__main__': cli() diff --git a/brother_ql/conversion.py b/brother_ql/conversion.py index 4ec9dec..a966593 100755 --- a/brother_ql/conversion.py +++ b/brother_ql/conversion.py @@ -73,7 +73,7 @@ def convert(qlr, images, label, **kwargs): except BrotherQLUnsupportedCmd: pass - for image in images: + for i, image in enumerate(images): if isinstance(image, Image.Image): im = image else: @@ -182,14 +182,16 @@ def convert(qlr, images, label, **kwargs): except BrotherQLUnsupportedCmd: pass qlr.add_margins(label_specs['feed_margin']) - try: - if compress: qlr.add_compression(True) - except BrotherQLUnsupportedCmd: - pass + if qlr.compression_support: + qlr.add_compression(compress) if red: qlr.add_raster_data(black_im, red_im) else: qlr.add_raster_data(im) - qlr.add_print() + + if i == len(images) - 1: + qlr.add_print() + else: + qlr.add_print(last_page=False) return qlr.data diff --git a/brother_ql/devicedependent.py b/brother_ql/devicedependent.py index ac90317..29cd52b 100644 --- a/brother_ql/devicedependent.py +++ b/brother_ql/devicedependent.py @@ -54,7 +54,7 @@ def _populate_model_legacy_structures(): if model.mode_setting: modesetting.append(model.identifier) if model.cutting: cuttingsupport.append(model.identifier) if model.expanded_mode: expandedmode.append(model.identifier) - if model.compression: compressionsupport.append(model.identifier) + if model.compression_support: compressionsupport.append(model.identifier) if model.two_color: two_color_support.append(model.identifier) def _populate_label_legacy_structures(): diff --git a/brother_ql/models.py b/brother_ql/models.py index d964f90..fbd4936 100644 --- a/brother_ql/models.py +++ b/brother_ql/models.py @@ -11,6 +11,7 @@ class Model(object): This class represents a printer model. All specifics of a certain model and the opcodes it supports should be contained in this class. """ + #: A string identifier given to each model implemented. Eg. 'QL-500'. identifier = attrib(type=str) #: Minimum and maximum number of rows or 'dots' that can be printed. @@ -31,40 +32,217 @@ class Model(object): expanded_mode = attrib(type=bool, default=True) #: Model has support for compressing the transmitted raster data. #: Some models with only USB connectivity don't support compression. - compression = attrib(type=bool, default=True) + compression_support = attrib(type=bool, default=True) #: Support for two color printing (black/red/white) #: available only on some newer models. two_color = attrib(type=bool, default=False) #: Number of NULL bytes needed for the invalidate command. num_invalidate_bytes = attrib(type=int, default=200) + #: Hardware IDs + series_code = attrib(type=int, default=0xFFFF) + model_code = attrib(type=int, default=0xFFFF) + product_id = attrib(type=int, default=0xFFFF) @property def name(self): return self.identifier ALL_MODELS = [ - Model('QL-500', (295, 11811), compression=False, mode_setting=False, expanded_mode=False, cutting=False), - Model('QL-550', (295, 11811), compression=False, mode_setting=False), - Model('QL-560', (295, 11811), compression=False, mode_setting=False), - Model('QL-570', (150, 11811), compression=False, mode_setting=False), - Model('QL-580N', (150, 11811)), - Model('QL-600', (150, 11811)), - Model('QL-650TD', (295, 11811)), - Model('QL-700', (150, 11811), compression=False, mode_setting=False), - Model('QL-710W', (150, 11811)), - Model('QL-720NW', (150, 11811)), - Model('QL-800', (150, 11811), two_color=True, compression=False, num_invalidate_bytes=400), - Model('QL-810W', (150, 11811), two_color=True, num_invalidate_bytes=400), - Model('QL-820NWB',(150, 11811), two_color=True, num_invalidate_bytes=400), - Model('QL-1050', (295, 35433), number_bytes_per_row=162, additional_offset_r=44), - Model('QL-1060N', (295, 35433), number_bytes_per_row=162, additional_offset_r=44), - Model('QL-1100', (301, 35434), number_bytes_per_row=162, additional_offset_r=44), - Model('QL-1110NWB',(301, 35434), number_bytes_per_row=162, additional_offset_r=44), - Model('QL-1115NWB',(301, 35434), number_bytes_per_row=162, additional_offset_r=44), - Model('PT-E550W', (31, 14172), number_bytes_per_row=16), - Model('PT-P750W', (31, 14172), number_bytes_per_row=16), - Model('PT-P900W', (57, 28346), number_bytes_per_row=70), - Model('PT-P950NW', (57, 28346), number_bytes_per_row=70), + Model( + identifier="QL-500", + min_max_length_dots=(295, 11811), + compression_support=False, + mode_setting=False, + expanded_mode=False, + cutting=False, + series_code=0x30, + model_code=0x4F, + product_id=0x2015, + ), + Model( + identifier="QL-550", + min_max_length_dots=(295, 11811), + compression_support=False, + mode_setting=False, + series_code=0x30, + model_code=0x4F, + product_id=0x2016, + ), + Model( + identifier="QL-560", + min_max_length_dots=(295, 11811), + compression_support=False, + mode_setting=False, + series_code=0x34, + model_code=0x31, + product_id=0x2027, + ), + Model( + identifier="QL-570", + min_max_length_dots=(150, 11811), + compression_support=False, + mode_setting=False, + series_code=0x34, + model_code=0x32, + product_id=0x2028, + ), + Model( + identifier="QL-580N", + min_max_length_dots=(150, 11811), + series_code=0x34, + model_code=0x33, + product_id=0x2029, + ), + Model( + identifier="QL-600", + min_max_length_dots=(150, 11811), + series_code=0x34, + model_code=0x47, + product_id=0x20C0, + ), + Model( + identifier="QL-650TD", + min_max_length_dots=(295, 11811), + series_code=0x30, + model_code=0x51, + product_id=0x201B, + ), + Model( + identifier="QL-700", + min_max_length_dots=(150, 11811), + compression_support=False, + mode_setting=False, + series_code=0x34, + model_code=0x35, + product_id=0x2042, + ), + Model( + identifier="QL-710W", + min_max_length_dots=(150, 11811), + series_code=0x34, + model_code=0x36, + product_id=0x2043, + ), + Model( + identifier="QL-720NW", + min_max_length_dots=(150, 11811), + series_code=0x34, + model_code=0x37, + product_id=0x2044, + ), + Model( + identifier="QL-800", + min_max_length_dots=(150, 11811), + two_color=True, + compression_support=False, + num_invalidate_bytes=400, + series_code=0x34, + model_code=0x38, + product_id=0x209B, + ), + Model( + identifier="QL-810W", + min_max_length_dots=(150, 11811), + two_color=True, + num_invalidate_bytes=400, + series_code=0x34, + model_code=0x39, + product_id=0x209C, + ), + Model( + identifier="QL-820NWB", + min_max_length_dots=(150, 11811), + two_color=True, + num_invalidate_bytes=400, + series_code=0x34, + model_code=0x41, + product_id=0x209D, + ), + Model( + identifier="QL-1050", + min_max_length_dots=(295, 35433), + number_bytes_per_row=162, + additional_offset_r=44, + series_code=0x30, + model_code=0x50, + product_id=0x2020, + ), + Model( + identifier="QL-1060N", + min_max_length_dots=(295, 35433), + number_bytes_per_row=162, + additional_offset_r=44, + series_code=0x34, + model_code=0x34, + product_id=0x202A, + ), + Model( + identifier="QL-1100", + min_max_length_dots=(301, 35434), + number_bytes_per_row=162, + additional_offset_r=44, + series_code=0x34, + model_code=0x43, + product_id=0x20A7, + ), + Model( + identifier="QL-1110NWB", + min_max_length_dots=(301, 35434), + number_bytes_per_row=162, + additional_offset_r=44, + series_code=0x34, + model_code=0x44, + product_id=0x20A8, + ), + Model( + identifier="QL-1115NWB", + min_max_length_dots=(301, 35434), + number_bytes_per_row=162, + additional_offset_r=44, + series_code=0x34, + model_code=0x45, + product_id=0x20AB, + ), + Model( + identifier="PT-E550W", + min_max_length_dots=(31, 14172), + number_bytes_per_row=16, + series_code=0x30, + model_code=0x68, + product_id=0x2060, + ), + Model( + identifier="PT-P700", + min_max_length_dots=(31, 7086), + number_bytes_per_row=16, + series_code=0x30, + model_code=0x67, + product_id=0x2061, + ), + Model( + identifier="PT-P750W", + min_max_length_dots=(31, 7086), + number_bytes_per_row=16, + series_code=0x30, + model_code=0x68, + product_id=0x2062, + ), + Model( + identifier="PT-P900W", + min_max_length_dots=(57, 28346), + number_bytes_per_row=70, + series_code=0x30, + model_code=0x69, + product_id=0x2085, + ), + Model( + identifier="PT-P950NW", + min_max_length_dots=(57, 28346), + number_bytes_per_row=70, + series_code=0x30, + model_code=0x70, + product_id=0x2086, + ), ] class ModelsManager(ElementsManager): diff --git a/brother_ql/raster.py b/brother_ql/raster.py index 4add429..ea3ac58 100644 --- a/brother_ql/raster.py +++ b/brother_ql/raster.py @@ -57,15 +57,17 @@ def __init__(self, model='QL-500'): self.cut_at_end = True self.dpi_600 = False self.two_color_printing = False - self._compression = False + self.compression_enabled = False + self.compression_support = False self.exception_on_warning = False self.half_cut = True - self.no_chain_printing = False + self.no_chain_printing = True self.num_invalidate_bytes = 200 for m in ModelsManager().iter_elements(): if self.model == m.identifier: self.num_invalidate_bytes = m.num_invalidate_bytes + self.compression_support = m.compression_support break def _warn(self, problem, kind=BrotherQLRasterError): @@ -167,10 +169,7 @@ def add_autocut(self, autocut = False): self._unsupported("Trying to call add_autocut with a printer that doesn't support it") return self.data += b'\x1B\x69\x4D' # ESC i M - if self.model.startswith('PT'): - self.data += bytes([autocut << 5]) - else: - self.data += bytes([autocut << 6]) + self.data += bytes([autocut << 6]) def add_cut_every(self, n=1): if self.model not in cuttingsupport: @@ -216,7 +215,7 @@ def add_compression(self, compression=True): if self.model not in compressionsupport: self._unsupported("Trying to set compression on a printer that doesn't support it") return - self._compression = compression + self.compression_enabled = compression self.data += b'\x4D' # M self.data += bytes([compression << 1]) @@ -258,7 +257,7 @@ def add_raster_data(self, image, second_image=None): while start + row_len <= frame_len: for i, frame in enumerate(frames): row = frame[start:start+row_len] - if self._compression: + if self.compression_enabled: row = packbits.encode(row) translen = len(row) # number of bytes to be transmitted if self.model.startswith('PT'): diff --git a/brother_ql/reader.py b/brother_ql/reader.py index ecf2fa5..bf4f7a0 100755 --- a/brother_ql/reader.py +++ b/brother_ql/reader.py @@ -167,6 +167,8 @@ def interpret_response(data): raise NameError("Printer response doesn't start with the usual header (80:20:42)", hex_format(data)) for i, byte_name in enumerate(RESP_BYTE_NAMES): logger.debug('Byte %2d %24s %02X', i, byte_name+':', data[i]) + series_code = data[3] + model_code = data[4] errors = [] error_info_1 = data[8] error_info_2 = data[9] @@ -204,6 +206,8 @@ def interpret_response(data): logger.error("Unknown phase type %02X", phase_type) response = { + 'series_code': series_code, + 'model_code': model_code, 'status_type': status_type, 'phase_type': phase_type, 'media_type': media_type,