# MIT License # # Copyright (c) 2024 ysfchn / Yusuf Cihan # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import asyncio from io import BytesIO from pathlib import Path from PIL import Image, ImageChops from bleak import BleakScanner, BleakClient from typing import List, TYPE_CHECKING from dymo_bluetooth.printer import Canvas, Result, command_print if TYPE_CHECKING: from bleak.backends.device import BLEDevice from bleak.backends.characteristic import BleakGATTCharacteristic SERVICE_UUID = "be3dd650-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d") PRINT_REQUEST_UUID = "be3dd651-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d") PRINT_REPLY_UUID = "be3dd652-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d") # Not used in the actual vendor app. UNKNOWN_UUID = "be3dd653-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d") def is_espressif(mac : str): """ Returns True if given MAC address is in the range of Espressif Inc. """ start_block = 0x58_CF_79 << 24 end_block = start_block + (1 << 24) # Exclusive mac_value = int(mac.replace(":", ""), base = 16) if (mac_value >= start_block) and (mac_value < end_block): return True return False class Printer: def __init__(self, impl : "BLEDevice") -> None: self._impl = impl self._client = BleakClient(self._impl) async def connect(self): if self._client.is_connected: return await self._client.connect() async def disconnect(self): if not self._client.is_connected: return await self._client.disconnect() async def print(self, canvas : Canvas): if not self._client.is_connected: raise Exception("Printer is not connected!") print_request : "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REQUEST_UUID) # type: ignore # noqa: E501 print_reply : "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REPLY_UUID) # type: ignore # noqa: E501 future : asyncio.Future[Result] = asyncio.Future() should_discard : bool = False # Printer sends two messages, first is the PRINTING, and second one is the # printing result. So we discard the first message. async def reply_get(_, data : bytearray): # noqa: E501 nonlocal should_discard result = Result.from_bytes(data) if (not should_discard) and (result.value in [0, 1]): should_discard = True return # This is the second reply, which holds the actual status code. future.set_result(result) await self._client.start_notify(print_reply, reply_get) for chunk in command_print(canvas): await self._client.write_gatt_char(print_request, chunk, True) return await future async def discover_printers(max_timeout : int = 5) -> List[Printer]: """ Searches for printers nearby and returns a list of Printer objects. If no printer has found in the initial search, waits for scanning until the max timeout has been reached. """ printers : List[Printer] = [] waited_total = 0 async with BleakScanner(service_uuids = [SERVICE_UUID]) as scanner: while True: # TODO: In some cases, advetisement data may be non-null, containing # additional metadata about printer state but it is not implemented yet. for device, _ in scanner.discovered_devices_and_advertisement_data.values(): # noqa: E501 if not is_espressif(device.address): continue # Also match the "Letratag" name just to be sure that we are looking for # the right device, since there are also other products made by DYMO. if (device.name or "") == f"Letratag {device.address.replace(':', '')}": printers.append(Printer(device)) # Do we have any candidate printers? If so, return the found printers. # Otherwise, wait for the next scans until we found any. if printers: break elif waited_total >= max_timeout: return [] await asyncio.sleep(0.5) waited_total += 0.5 return printers def convert_image_to_canvas( image : Image.Image, dither : bool = True, trim : bool = False ): """ Converts an Pillow Image to a Canvas object. """ output = image.convert("1", dither = \ Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE ) # If trim is enabled, discard trailing and leading blank lines. if trim: mask = Image.new("1", output.size, color = 255) diff = ImageChops.difference(output, mask) output = output.crop(diff.getbbox(alpha_only = False)) # Shrink the image from the center if it exceeds the print height, # or max printable width. if (output.height > Canvas.HEIGHT): start_y = int(output.height / 2) - int(Canvas.HEIGHT / 2) output = output.crop( (0, start_y, output.width, start_y + Canvas.HEIGHT) ) elif (output.height < Canvas.HEIGHT): raise ValueError("Image is too small, resizing not implemented.") if (output.width) > Canvas.MAX_WIDTH: raise ValueError("Image is too large, resizing not implemented.") # Convert image to pixel array. canvas = Canvas() for w in range(output.width): for h in range(output.height): pixel = output.getpixel((w, h, )) canvas.set_pixel(w, h, not pixel) return canvas def create_image(path : Path, dither : bool = True): """ Converts an image file in given path to Canvas. """ buffer = BytesIO() with path.open("rb") as op: buffer.write(op.read()) buffer.seek(0) image = Image.open(buffer) return convert_image_to_canvas(image, dither) def create_code_128(text : str): """ Creates a Code 128 barcode and dumps to Canvas. """ try: from barcode import Code128 # type: ignore from barcode.writer import ImageWriter # type: ignore except ModuleNotFoundError: raise Exception("This method requires 'python-barcode' to be installed.") imwrite = ImageWriter() imwrite.dpi = 200 code = Code128(text, writer = imwrite) return convert_image_to_canvas(code.render(text = ""), dither = False, trim = True)