Initial commit
This commit is contained in:
38
dymo_bluetooth/__init__.py
Normal file
38
dymo_bluetooth/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
|
||||
__all__ = [
|
||||
"discover_printers",
|
||||
"Canvas",
|
||||
"Result",
|
||||
"create_image",
|
||||
"create_code_128",
|
||||
"convert_image_to_canvas"
|
||||
]
|
||||
|
||||
from dymo_bluetooth.bluetooth import (
|
||||
discover_printers,
|
||||
create_image,
|
||||
create_code_128,
|
||||
convert_image_to_canvas
|
||||
)
|
||||
from dymo_bluetooth.printer import Canvas, Result
|
78
dymo_bluetooth/__main__.py
Normal file
78
dymo_bluetooth/__main__.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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.
|
||||
|
||||
from argparse import ArgumentParser, BooleanOptionalAction
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from dymo_bluetooth.bluetooth import discover_printers, create_image
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
async def print_image(
|
||||
input_file : Path,
|
||||
max_timeout : int,
|
||||
stretch_factor : int,
|
||||
use_dither : bool
|
||||
):
|
||||
canvas = create_image(input_file, dither = use_dither)
|
||||
canvas = canvas.stretch_image(factor = stretch_factor)
|
||||
printers = await discover_printers(max_timeout)
|
||||
if not printers:
|
||||
print("Couldn't find any printers in given timeout!")
|
||||
exit(1)
|
||||
printer = printers[0]
|
||||
print(f"Starting to print on {printer._impl.address}...")
|
||||
await printer.connect()
|
||||
await printer.print(canvas)
|
||||
|
||||
|
||||
def main():
|
||||
module_name = cast(str, sys.modules[__name__].__file__).split("/")[-2]
|
||||
args = ArgumentParser(
|
||||
prog = f"python -m {module_name}",
|
||||
description = (
|
||||
"Print monochrome labels with DYMO LetraTag LT-200B label printer over "
|
||||
"Bluetooth."
|
||||
)
|
||||
)
|
||||
args.add_argument("image", help = "Image file to print. Must be 32 pixels in height.", type = Path, metavar = "IMAGE") # noqa: E501
|
||||
args.add_argument("--timeout", default = 5, type = int, help = "Maximum timeout to search for printers.") # noqa: E501
|
||||
args.add_argument("--dither", default = True, action = BooleanOptionalAction,
|
||||
help = "Use or don't use dithering when converting it to monochrome image."
|
||||
)
|
||||
args.add_argument("--factor", default = 2, type = int,
|
||||
help = "Stretch the image N times. Default is 2, otherwise printer" + \
|
||||
"will print images too thin."
|
||||
)
|
||||
parsed = args.parse_args()
|
||||
input_file = cast(Path, parsed.image).absolute()
|
||||
max_timeout = cast(int, parsed.timeout)
|
||||
stretch_factor = cast(int, parsed.factor)
|
||||
use_dither = cast(bool, parsed.dither)
|
||||
if not input_file.exists():
|
||||
print(f"Given image file doesn't exists: {input_file.as_posix()}")
|
||||
exit(1)
|
||||
asyncio.run(print_image(input_file, max_timeout, stretch_factor, use_dither))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
184
dymo_bluetooth/bluetooth.py
Normal file
184
dymo_bluetooth/bluetooth.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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)
|
392
dymo_bluetooth/printer.py
Normal file
392
dymo_bluetooth/printer.py
Normal file
@@ -0,0 +1,392 @@
|
||||
# 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.
|
||||
|
||||
from enum import Enum
|
||||
from io import SEEK_END, BytesIO
|
||||
from typing import Union
|
||||
import math
|
||||
|
||||
BytesLike = Union[bytes, bytearray]
|
||||
|
||||
|
||||
class DirectiveCommand(Enum):
|
||||
"""
|
||||
Directives that accepted by the printer.
|
||||
"""
|
||||
START = "s"
|
||||
MEDIA_TYPE = "M"
|
||||
PRINT_DENSITY = "C" # Unused
|
||||
PRINT_DATA = "D"
|
||||
FORM_FEED = "E"
|
||||
STATUS = "A"
|
||||
END = "Q"
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
# 27 = ASCII escape sequence
|
||||
return bytes((27, ord(self.value), ))
|
||||
|
||||
|
||||
class Result(Enum):
|
||||
|
||||
# Printing has been completed (supposedly). This may not always mean that a
|
||||
# label has been printed out from the printer. See FAILED_NO_CASETTE status
|
||||
# below for details.
|
||||
SUCCESS = 0
|
||||
|
||||
# PRINTING (or SUCCESS) is 1
|
||||
|
||||
# Print failed due to some unknown reason.
|
||||
FAILED = 2
|
||||
|
||||
# Printing has been completed but battery is low.
|
||||
SUCCESS_LOW_BATTERY = 3
|
||||
|
||||
# Print failed due to being cancelled.
|
||||
FAILED_CANCEL = 4
|
||||
|
||||
# FAILED (with a different status value) is 5
|
||||
|
||||
# Print failed due to the low batteries.
|
||||
FAILED_LOW_BATTERY = 6
|
||||
|
||||
# Failed due to casette not inserted. On my tests, printer never sends this
|
||||
# status, instead it will spin its gear even casette is not inserted, and
|
||||
# will send a SUCCESS status instead, so there is no way to check if
|
||||
# casette is actually inserted.
|
||||
FAILED_NO_CASETTE = 7
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data : BytesLike):
|
||||
if data[0] != 27:
|
||||
raise ValueError("Not a valid result value; 1st byte must be 0x1b (27)")
|
||||
if chr(data[1]) != "R":
|
||||
raise ValueError("Not a valid result value; 2nd byte must be 0x52 (82)")
|
||||
# There is a value 5, which also means printing has completed but has
|
||||
# a different status value, so we return FAILED since that's what it means.
|
||||
if data[2] == 5:
|
||||
return cls(Result.FAILED.value)
|
||||
# There is a value 1, which also means printing has completed but has
|
||||
# a different status value, so we return SUCCESS since that's what it means.
|
||||
if data[2] == 1:
|
||||
return cls(Result.SUCCESS.value)
|
||||
return cls(data[2])
|
||||
|
||||
|
||||
class Canvas:
|
||||
"""
|
||||
An implementation of 1-bit monochrome little-endian encoded 32 pixel height
|
||||
images for printing them to the label.
|
||||
|
||||
1 byte equals 8 bits, and each bit specifies if pixel should be black (= 1)
|
||||
or white (= 0). The generated image will contain a multiple of 4 bytes, equaling
|
||||
the label height in pixels (4 * 8 = 32 pixels).
|
||||
"""
|
||||
|
||||
__slots__ = ("buffer", )
|
||||
|
||||
HEIGHT = 32
|
||||
MAX_WIDTH = 8186
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer = BytesIO()
|
||||
|
||||
def get_pixel(self, x : int, y : int):
|
||||
"""
|
||||
Gets the pixel in given coordinates.
|
||||
Returns True if pixel is black, otherwise False.
|
||||
"""
|
||||
if y >= Canvas.HEIGHT:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.HEIGHT} pixels in height.")
|
||||
if x >= Canvas.MAX_WIDTH:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.MAX_WIDTH} pixels in width.")
|
||||
|
||||
# Get the byte containing the pixel value of given coordinates.
|
||||
x_offset = math.ceil((x * Canvas.HEIGHT) / 8)
|
||||
y_offset = 3 - math.floor(y / 8)
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
|
||||
# Check if there is a bit value in given line.
|
||||
read = self.buffer.read(1)
|
||||
value = int.from_bytes(read or b"\x00", "little")
|
||||
is_black = bool(value & (1 << (7 - (y % 8))))
|
||||
return is_black
|
||||
|
||||
def set_pixel(self, x : int, y : int, color : bool):
|
||||
"""
|
||||
Sets a pixel to given coordinates.
|
||||
Setting color to True will paint a black color.
|
||||
"""
|
||||
if y >= Canvas.HEIGHT:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.HEIGHT} pixels in height.")
|
||||
if x >= Canvas.MAX_WIDTH:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.MAX_WIDTH} pixels in width.")
|
||||
|
||||
# Get the byte containing the pixel value of given coordinates.
|
||||
x_offset = math.ceil((x * Canvas.HEIGHT) / 8)
|
||||
y_offset = 3 - math.floor(y / 8)
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
|
||||
# Get the one of four slices in line in the given coordinates. Add the bit in
|
||||
# given location if color is black, otherwise exclude the bit to make it white.
|
||||
read = self.buffer.read(1)
|
||||
value = int.from_bytes(read or b"\x00", "little")
|
||||
if color:
|
||||
value = value | (1 << (7 - (y % 8)))
|
||||
else:
|
||||
value = value & ~(1 << (7 - (y % 8)))
|
||||
|
||||
# Change the current part of the line with modified one.
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
self.buffer.write(bytes([value]))
|
||||
|
||||
def stretch_image(self, factor : int = 2):
|
||||
"""
|
||||
Stretches image to the right in N times, and returns a new Canvas
|
||||
containing the stretched image. Factor 1 results in the same image.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError("Stretch factor must be at least 1!")
|
||||
canvas = Canvas()
|
||||
for x in range(self.get_width()):
|
||||
for y in range(Canvas.HEIGHT):
|
||||
pixel = self.get_pixel(x, y)
|
||||
start_x = (x * factor)
|
||||
for i in range(factor):
|
||||
canvas.set_pixel(start_x + i, y, pixel)
|
||||
return canvas
|
||||
|
||||
def pad_image(self, width : int):
|
||||
"""
|
||||
Adds blank spacing to both sides of the image, until the image
|
||||
reaches to the given width. Returns a new Canvas containing the
|
||||
modified image. If image is already longer than given width, it
|
||||
will return the current Canvas.
|
||||
"""
|
||||
current_width = self.get_width()
|
||||
if current_width >= width:
|
||||
return self
|
||||
canvas = Canvas()
|
||||
left_padding = math.ceil(width / 2)
|
||||
for x in range(current_width):
|
||||
for y in range(Canvas.HEIGHT):
|
||||
canvas.set_pixel(left_padding + x, y, self.get_pixel(x, y))
|
||||
for i in range(Canvas.HEIGHT):
|
||||
canvas.set_pixel(current_width + left_padding, i, False)
|
||||
return canvas
|
||||
|
||||
def print(self):
|
||||
"""
|
||||
Prints the image to the console.
|
||||
"""
|
||||
for y in range(Canvas.HEIGHT):
|
||||
for x in range(self.get_width()):
|
||||
print("█" if self.get_pixel(x, y) else "░", end = "")
|
||||
print()
|
||||
|
||||
def get_width(self):
|
||||
"""
|
||||
Gets the painted width of the image.
|
||||
"""
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
cur = self.buffer.tell()
|
||||
return math.ceil(cur / 4)
|
||||
|
||||
def get_size(self):
|
||||
"""
|
||||
Gets the width and height of the image in tuple.
|
||||
"""
|
||||
return (self.get_width(), Canvas.HEIGHT, )
|
||||
|
||||
def get_image(self):
|
||||
"""
|
||||
Gets the created image with added blank padding.
|
||||
"""
|
||||
self.buffer.seek(0)
|
||||
image = self.buffer.read()
|
||||
return image + (b"\x00" * (self.buffer.tell() % 4))
|
||||
|
||||
def empty(self):
|
||||
"""
|
||||
Makes all pixels in the canvas in white. Canvas size won't be changed.
|
||||
"""
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
byte_count = self.buffer.tell()
|
||||
self.buffer.seek(0)
|
||||
self.buffer.truncate(0)
|
||||
self.buffer.seek(byte_count)
|
||||
self.buffer.write(b"\x00")
|
||||
|
||||
def copy(self):
|
||||
"""
|
||||
Creates a copy of this canvas.
|
||||
"""
|
||||
canvas = Canvas()
|
||||
self.buffer.seek(0)
|
||||
canvas.buffer.write(self.buffer.read())
|
||||
return canvas
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Clears the canvas. Canvas size will be changed to 0.
|
||||
"""
|
||||
self.buffer.seek(0)
|
||||
self.buffer.truncate(0)
|
||||
|
||||
def __eq__(self, value: object, /) -> bool:
|
||||
if not isinstance(value, Canvas):
|
||||
return False
|
||||
return value.get_image() == self.get_image()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
byte_size = self.buffer.tell()
|
||||
image_size = "x".join(map(str, self.get_size()))
|
||||
return f"<{self.__class__.__name__} image={image_size} bytes={byte_size}>"
|
||||
|
||||
|
||||
class DirectiveBuilder:
|
||||
"""
|
||||
Builds directives for the printer.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def start():
|
||||
# [154, 2, 0, 0] is the "job ID".
|
||||
# Without that, printer won't print anything but a small blank label.
|
||||
# This the only "job ID" that the printer uses in start directive, it is not
|
||||
# related with some sort of queue or anything else, just a constant value.
|
||||
return bytes([*DirectiveCommand.START.to_bytes(), 154, 2, 0, 0])
|
||||
|
||||
@staticmethod
|
||||
def media_type(value : int):
|
||||
return bytes([*DirectiveCommand.MEDIA_TYPE.to_bytes(), value])
|
||||
|
||||
@staticmethod
|
||||
def form_feed():
|
||||
return bytes(DirectiveCommand.FORM_FEED.to_bytes())
|
||||
|
||||
@staticmethod
|
||||
def status():
|
||||
return bytes(DirectiveCommand.STATUS.to_bytes())
|
||||
|
||||
@staticmethod
|
||||
def end():
|
||||
return bytes(DirectiveCommand.END.to_bytes())
|
||||
|
||||
@staticmethod
|
||||
def print(
|
||||
data : BytesLike,
|
||||
image_width : int,
|
||||
bits_per_pixel : int,
|
||||
alignment : int
|
||||
):
|
||||
size = \
|
||||
image_width.to_bytes(4, "little") + \
|
||||
Canvas.HEIGHT.to_bytes(4, "little")
|
||||
return bytes([
|
||||
*DirectiveCommand.PRINT_DATA.to_bytes(),
|
||||
bits_per_pixel,
|
||||
alignment,
|
||||
*size,
|
||||
*data
|
||||
])
|
||||
|
||||
|
||||
def create_payload(data : BytesLike, is_print : bool = False):
|
||||
"""
|
||||
Creates a final payload to be sent to the printer from the input data.
|
||||
|
||||
Each iteration of this generator will yield the bytes that needs to be sent over
|
||||
the Bluetooth for each GATT write transaction.
|
||||
"""
|
||||
# Longer inputs needs to be splitted.
|
||||
CHUNK_SIZE = 500
|
||||
# Magic value.
|
||||
MAGIC = [18, 52]
|
||||
# Length of the data in 4 bytes.
|
||||
length = len(data).to_bytes(4, "little")
|
||||
# byte[9] = [255, 240, 18, 52, ...LENGTH{4}, CHECKSUM]
|
||||
header = bytearray([
|
||||
255, # Preamble
|
||||
240, # Flags
|
||||
*MAGIC,
|
||||
*length
|
||||
])
|
||||
# For checksum, we get the sum of all bytes, then get the first byte of the sum.
|
||||
checksum = sum(header) & 0xFF
|
||||
header.append(checksum)
|
||||
assert len(header) == 9, "Header must be 9 bytes"
|
||||
# Payloads other than writing doesn't require chunking,
|
||||
# so the input data can be added as-is.
|
||||
if not is_print:
|
||||
header.extend(data)
|
||||
yield header
|
||||
# However, write payloads must be chunked properly.
|
||||
else:
|
||||
# First yield the header.
|
||||
yield header
|
||||
# Split chunk in each 500 bytes.
|
||||
for index, step in enumerate(range(0, len(data), CHUNK_SIZE)):
|
||||
current_chunk = bytearray()
|
||||
chunk = data[step : step + CHUNK_SIZE]
|
||||
# TODO: Not sure what is the purpose of the this, but the original
|
||||
# vendor app skips this index, so we do the same here.
|
||||
chunk_index = index + 1 if index >= 27 else index
|
||||
current_chunk.append(chunk_index)
|
||||
current_chunk.extend(chunk)
|
||||
# If this is the last chunk, append MAGIC to the end.
|
||||
if (step + CHUNK_SIZE) >= len(data):
|
||||
current_chunk.extend(MAGIC)
|
||||
yield current_chunk
|
||||
|
||||
|
||||
def command_print(
|
||||
canvas : Canvas
|
||||
):
|
||||
"""
|
||||
Creates a print directive.
|
||||
"""
|
||||
payload = bytearray()
|
||||
payload.extend(DirectiveBuilder.start())
|
||||
payload.extend(DirectiveBuilder.print(
|
||||
canvas.get_image(),
|
||||
canvas.get_width(),
|
||||
bits_per_pixel = 1,
|
||||
alignment = 2
|
||||
))
|
||||
payload.extend(DirectiveBuilder.form_feed())
|
||||
payload.extend(DirectiveBuilder.status())
|
||||
payload.extend(DirectiveBuilder.end())
|
||||
return create_payload(payload, is_print = True)
|
||||
|
||||
|
||||
def command_casette(
|
||||
media_type : int
|
||||
):
|
||||
"""
|
||||
Creates a casette directive.
|
||||
"""
|
||||
payload = bytearray()
|
||||
payload.extend(DirectiveBuilder.start())
|
||||
payload.extend(DirectiveBuilder.media_type(media_type))
|
||||
payload.extend(DirectiveBuilder.end())
|
||||
return create_payload(payload)
|
Reference in New Issue
Block a user