diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e94cadb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + - id: check-merge-conflict + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.3 + hooks: + - id: ruff-format + - id: ruff + args: [--fix, --exit-non-zero-on-fix] diff --git a/README.md b/README.md deleted file mode 100644 index 3105e6e..0000000 --- a/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# (WIP) Niimbot printer client - - usage: niimprint [-h] -a ADDRESS [--no-check] [-d DENSITY] [-t TYPE] [-n QUANTITY] image - - Niimbot printer client - - positional arguments: - image PIL supported image file - - options: - -h, --help show this help message and exit - -a ADDRESS, --address ADDRESS - MAC address of target device - --no-check Skips image check - -d DENSITY, --density DENSITY - Printer density (1~3) - -t TYPE, --type TYPE Label type (1~3) - -n QUANTITY, --quantity QUANTITY - Number of copies diff --git a/examples/B21_30x15mm_240x120px.png b/examples/B21_30x15mm_240x120px.png new file mode 100644 index 0000000..73aee75 Binary files /dev/null and b/examples/B21_30x15mm_240x120px.png differ diff --git a/examples/B21_80x50mm_640x384px.png b/examples/B21_80x50mm_640x384px.png new file mode 100644 index 0000000..8bf6f11 Binary files /dev/null and b/examples/B21_80x50mm_640x384px.png differ diff --git a/niimprint/__init__.py b/niimprint/__init__.py new file mode 100644 index 0000000..6f0e992 --- /dev/null +++ b/niimprint/__init__.py @@ -0,0 +1 @@ +from .printer import BluetoothTransport, PrinterClient, SerialTransport diff --git a/niimprint/__main__.py b/niimprint/__main__.py index 28a5280..7abaa49 100644 --- a/niimprint/__main__.py +++ b/niimprint/__main__.py @@ -1,44 +1,76 @@ -import argparse -import printerclient -import printencoder +import re +import click from PIL import Image -import time -# import math -# mm_to_px = lambda x: math.ceil(x / 25.4 * 203) -# px_to_mm = lambda x: math.ceil(x / 25.4 * 203) +from niimprint import BluetoothTransport, PrinterClient, SerialTransport -if __name__ == '__main__': - parser = argparse.ArgumentParser( - description="Niimbot printer client") - parser.add_argument('-a', '--address', required=True, help="MAC address of target device") - parser.add_argument('--no-check', action='store_true', help="Skips image check") - parser.add_argument('-d', '--density', type=int, default=2, help="Printer density (1~3)") - parser.add_argument('-t', '--type', type=int, default=1, help="Label type (1~3)") - parser.add_argument('-n', '--quantity', type=int, default=1, help="Number of copies") - parser.add_argument('image', help="PIL supported image file") - args = parser.parse_args() - img = Image.open(args.image) - if img.width / img.height > 1: - # rotate clockwise 90deg, upper line (left line) prints first. - img = img.transpose(Image.ROTATE_270) - assert args.no_check or (img.width == 96 and img.height < 600) +@click.command("print") +@click.option( + "-m", + "--model", + type=click.Choice(["b21", "d11"], False), + default="b21", + show_default=True, + help="Niimbot printer model", +) +@click.option( + "-c", + "--conn", + type=click.Choice(["usb", "bluetooth"]), + default="usb", + show_default=True, + help="Connection type", +) +@click.option( + "-a", + "--addr", + help="Bluetooth MAC address OR serial device path", +) +@click.option( + "-d", + "--density", + type=click.IntRange(1, 5), + default=5, + show_default=True, + help="Print density", +) +@click.option( + "-i", + "--image", + type=click.Path(exists=True), + required=True, + help="Image path", +) +def print_cmd(model, conn, addr, density, image): + assert model != "d11", "D11 support may be broken (test yourself)" + assert conn != "bluetooth", "Bluetooth support may be broken (test yourself)" - printer = printerclient.PrinterClient(args.address) - printer.set_label_type(args.type) - printer.set_label_density(args.density) + if conn == "bluetooth": + addr = addr.upper() + assert re.fullmatch(r"([0-9A-F]{2}:){5}([0-9A-F]{2})", addr), "Bad MAC address" + transport = BluetoothTransport(addr) + if conn == "usb": + port = addr if addr is not None else "auto" + transport = SerialTransport(port=port) - printer.start_print() - printer.allow_print_clear() - printer.start_page_print() - printer.set_dimension(img.height, img.width) - printer.set_quantity(args.quantity) - for pkt in printencoder.naive_encoder(img): - printer._send(pkt) - printer.end_page_print() - while (a := printer.get_print_status())['page'] != args.quantity: - # print(a) - time.sleep(0.1) - printer.end_print() + if model == "b21": + # This may be wrong, but B21 doesn't accept anything larger. It's just shy of + # 50mm * 8 px/mm = 400px (for vertical space it's always 8 px/mm), so lgtm. + max_height_px = 384 + if model == "d11": + max_height_px = 100 # I don't have D11 to test + + # Image is printed left-to-right. Generally, we expect image width to be larger + # than image height, because that's the usual sticker aspect ratio. + image = Image.open(image) + assert image.width > image.height, "Are you sure image rotation is right?" + assert image.height <= max_height_px, f"Image height too big for {model}" + + printer = PrinterClient(transport) + printer.print_image(image, density=density) + + +if __name__ == "__main__": + print_cmd() diff --git a/niimprint/niimbotpacket.py b/niimprint/packet.py similarity index 71% rename from niimprint/niimbotpacket.py rename to niimprint/packet.py index 951520d..30cf078 100644 --- a/niimprint/niimbotpacket.py +++ b/niimprint/packet.py @@ -5,11 +5,11 @@ class NiimbotPacket: @classmethod def from_bytes(cls, pkt): - assert pkt[:2] == b'\x55\x55' - assert pkt[-2:] == b'\xaa\xaa' + assert pkt[:2] == b"\x55\x55" + assert pkt[-2:] == b"\xaa\xaa" type_ = pkt[2] len_ = pkt[3] - data = pkt[4:4+len_] + data = pkt[4 : 4 + len_] checksum = type_ ^ len_ for i in data: @@ -22,7 +22,9 @@ class NiimbotPacket: checksum = self.type ^ len(self.data) for i in self.data: checksum ^= i - return bytes((0x55, 0x55, self.type, len(self.data), *self.data, checksum, 0xaa, 0xaa)) + return bytes( + (0x55, 0x55, self.type, len(self.data), *self.data, checksum, 0xAA, 0xAA) + ) def __repr__(self): return f"" diff --git a/niimprint/printencoder.py b/niimprint/printencoder.py deleted file mode 100644 index 09be883..0000000 --- a/niimprint/printencoder.py +++ /dev/null @@ -1,29 +0,0 @@ -import PIL.Image as Image -import PIL.ImageOps as ImageOps -import struct -import niimbotpacket -import sys - -if sys.version_info.minor >= 10: - def countbitsofbytes(data): - return int.from_bytes(data, 'big').bit_count() -else: - def countbitsofbytes(data): - n = int.from_bytes(data, 'big') - # https://stackoverflow.com/a/9830282 - n = (n & 0x55555555) + ((n & 0xAAAAAAAA) >> 1) - n = (n & 0x33333333) + ((n & 0xCCCCCCCC) >> 2) - n = (n & 0x0F0F0F0F) + ((n & 0xF0F0F0F0) >> 4) - n = (n & 0x00FF00FF) + ((n & 0xFF00FF00) >> 8) - n = (n & 0x0000FFFF) + ((n & 0xFFFF0000) >> 16) - return n - -def naive_encoder(img): - img_data = ImageOps.invert(img.convert("L")).convert("1").tobytes() - - for x in range(img.height): - line_data = img_data[x*12:(x+1)*12] - counts = ( countbitsofbytes(line_data[i*4:(i+1)*4]) for i in range(3) ) - header = struct.pack('>H3BB', x, *counts, 1) - pkt = niimbotpacket.NiimbotPacket(0x85, header+line_data) - yield pkt diff --git a/niimprint/printer.py b/niimprint/printer.py new file mode 100644 index 0000000..dcd2d07 --- /dev/null +++ b/niimprint/printer.py @@ -0,0 +1,289 @@ +import abc +import enum +import logging +import math +import socket +import struct +import time + +import serial +from PIL import Image, ImageOps +from serial.tools.list_ports import comports as list_comports + +from niimprint.packet import NiimbotPacket + + +class InfoEnum(enum.IntEnum): + DENSITY = 1 + PRINTSPEED = 2 + LABELTYPE = 3 + LANGUAGETYPE = 6 + AUTOSHUTDOWNTIME = 7 + DEVICETYPE = 8 + SOFTVERSION = 9 + BATTERY = 10 + DEVICESERIAL = 11 + HARDVERSION = 12 + + +class RequestCodeEnum(enum.IntEnum): + GET_INFO = 64 # 0x40 + GET_RFID = 26 # 0x1A + HEARTBEAT = 220 # 0xDC + SET_LABEL_TYPE = 35 # 0x23 + SET_LABEL_DENSITY = 33 # 0x21 + START_PRINT = 1 # 0x01 + END_PRINT = 243 # 0xF3 + START_PAGE_PRINT = 3 # 0x03 + END_PAGE_PRINT = 227 # 0xE3 + ALLOW_PRINT_CLEAR = 32 # 0x20 + SET_DIMENSION = 19 # 0x13 + SET_QUANTITY = 21 # 0x15 + GET_PRINT_STATUS = 163 # 0xA3 + + +def _packet_to_int(x): + return int.from_bytes(x.data, "big") + + +class BaseTransport(metaclass=abc.ABCMeta): + @abc.abstractmethod + def read(self, length: int) -> bytes: + raise NotImplementedError + + @abc.abstractmethod + def write(self, data: bytes): + raise NotImplementedError + + +class BluetoothTransport(BaseTransport): + def __init__(self, address: str): + self._sock = socket.socket( + socket.AF_BLUETOOTH, + socket.SOCK_STREAM, + socket.BTPROTO_RFCOMM, + ) + self._sock.connect((address, 1)) + + def read(self, length: int) -> bytes: + return self._sock.recv(length) + + def write(self, data: bytes): + return self._sock.send(data) + + +class SerialTransport(BaseTransport): + def __init__(self, port: str = "auto"): + port = port if port != "auto" else self._detect_port() + self._serial = serial.Serial(port=port, baudrate=115200, timeout=0.5) + + def _detect_port(self): + all_ports = list(list_comports()) + if len(all_ports) == 0: + raise RuntimeError("No serial ports detected") + if len(all_ports) > 1: + msg = "Too many serial ports, please select one via SERIAL_PORT env var:" + for port, desc, hwid in all_ports: + msg += f"\n- {port} : {desc} [{hwid}]" + raise RuntimeError(msg) + return all_ports[0][0] + + def read(self, length: int) -> bytes: + return self._serial.read(length) + + def write(self, data: bytes): + return self._serial.write(data) + + +class PrinterClient: + def __init__(self, transport): + self._transport = transport + self._packetbuf = bytearray() + + def print_image(self, image: Image, density: int = 3): + # Internally, we're slicing the image vertically, so it's convenient to rotate + image = image.transpose(Image.ROTATE_270) + self.set_label_density(density) + self.set_label_type(1) + self.start_print() + # self.allow_print_clear() # Something unsupported in protocol decoding (B21) + self.start_page_print() + self.set_dimension(image.height, image.width) + # self.set_quantity(1) # Same thing (B21) + for pkt in self._encode_image(image): + self._send(pkt) + self.end_page_print() + while not self.end_print(): + time.sleep(0.1) + + def _encode_image(self, image: Image): + img = ImageOps.invert(image.convert("L")).convert("1") + for y in range(img.height): + line_data = [img.getpixel((x, y)) for x in range(img.width)] + line_data = "".join("0" if pix == 0 else "1" for pix in line_data) + line_data = int(line_data, 2).to_bytes(math.ceil(img.width / 8), "big") + counts = (0, 0, 0) # It seems like you can always send zeros + header = struct.pack(">H3BB", y, *counts, 1) + pkt = NiimbotPacket(0x85, header + line_data) + yield pkt + + def _recv(self): + packets = [] + self._packetbuf.extend(self._transport.read(1024)) + while len(self._packetbuf) > 4: + pkt_len = self._packetbuf[3] + 7 + if len(self._packetbuf) >= pkt_len: + packet = NiimbotPacket.from_bytes(self._packetbuf[:pkt_len]) + self._log_buffer(" ", packet.to_bytes()) + packets.append(packet) + del self._packetbuf[:pkt_len] + return packets + + def _send(self, packet): + self._transport.write(packet.to_bytes()) + + def _log_buffer(self, prefix: str, buff: bytes): + msg = ":".join(f"{i:#04x}"[-2:] for i in buff) + logging.debug(msg) + + def _transceive(self, reqcode, data, respoffset=1): + respcode = respoffset + reqcode + packet = NiimbotPacket(reqcode, data) + self._log_buffer("-->", packet.to_bytes()) + self._send(packet) + resp = None + for _ in range(6): + for packet in self._recv(): + if packet.type == 219: + raise ValueError + elif packet.type == 0: + raise NotImplementedError + elif packet.type == respcode: + resp = packet + if resp: + return resp + time.sleep(0.1) + return resp + + def get_info(self, key): + if packet := self._transceive(RequestCodeEnum.GET_INFO, bytes((key,)), key): + match key: + case InfoEnum.DEVICESERIAL: + return packet.data.hex() + case InfoEnum.SOFTVERSION: + return _packet_to_int(packet) / 100 + case InfoEnum.HARDVERSION: + return _packet_to_int(packet) / 100 + case _: + return _packet_to_int(packet) + else: + return None + + def get_rfid(self): + packet = self._transceive(RequestCodeEnum.GET_RFID, b"\x01") + data = packet.data + + if data[0] == 0: + return None + uuid = data[0:8].hex() + idx = 8 + + barcode_len = data[idx] + idx += 1 + barcode = data[idx : idx + barcode_len].decode() + + idx += barcode_len + serial_len = data[idx] + idx += 1 + serial = data[idx : idx + serial_len].decode() + + idx += serial_len + total_len, used_len, type_ = struct.unpack(">HHB", data[idx:]) + return { + "uuid": uuid, + "barcode": barcode, + "serial": serial, + "used_len": used_len, + "total_len": total_len, + "type": type_, + } + + def heartbeat(self): + packet = self._transceive(RequestCodeEnum.HEARTBEAT, b"\x01") + closingstate = None + powerlevel = None + paperstate = None + rfidreadstate = None + + match len(packet.data): + case 20: + paperstate = packet.data[18] + rfidreadstate = packet.data[19] + case 13: + closingstate = packet.data[9] + powerlevel = packet.data[10] + paperstate = packet.data[11] + rfidreadstate = packet.data[12] + case 19: + closingstate = packet.data[15] + powerlevel = packet.data[16] + paperstate = packet.data[17] + rfidreadstate = packet.data[18] + case 10: + closingstate = packet.data[8] + powerlevel = packet.data[9] + rfidreadstate = packet.data[8] + case 9: + closingstate = packet.data[8] + + return { + "closingstate": closingstate, + "powerlevel": powerlevel, + "paperstate": paperstate, + "rfidreadstate": rfidreadstate, + } + + def set_label_type(self, n): + assert 1 <= n <= 3 + packet = self._transceive(RequestCodeEnum.SET_LABEL_TYPE, bytes((n,)), 16) + return bool(packet.data[0]) + + def set_label_density(self, n): + assert 1 <= n <= 5 # B21 has 5 levels, not sure for D11 + packet = self._transceive(RequestCodeEnum.SET_LABEL_DENSITY, bytes((n,)), 16) + return bool(packet.data[0]) + + def start_print(self): + packet = self._transceive(RequestCodeEnum.START_PRINT, b"\x01") + return bool(packet.data[0]) + + def end_print(self): + packet = self._transceive(RequestCodeEnum.END_PRINT, b"\x01") + return bool(packet.data[0]) + + def start_page_print(self): + packet = self._transceive(RequestCodeEnum.START_PAGE_PRINT, b"\x01") + return bool(packet.data[0]) + + def end_page_print(self): + packet = self._transceive(RequestCodeEnum.END_PAGE_PRINT, b"\x01") + return bool(packet.data[0]) + + def allow_print_clear(self): + packet = self._transceive(RequestCodeEnum.ALLOW_PRINT_CLEAR, b"\x01", 16) + return bool(packet.data[0]) + + def set_dimension(self, w, h): + packet = self._transceive( + RequestCodeEnum.SET_DIMENSION, struct.pack(">HH", w, h) + ) + return bool(packet.data[0]) + + def set_quantity(self, n): + packet = self._transceive(RequestCodeEnum.SET_QUANTITY, struct.pack(">H", n)) + return bool(packet.data[0]) + + def get_print_status(self): + packet = self._transceive(RequestCodeEnum.GET_PRINT_STATUS, b"\x01", 16) + page, progress1, progress2 = struct.unpack(">HBB", packet.data) + return {"page": page, "progress1": progress1, "progress2": progress2} diff --git a/niimprint/printerclient.py b/niimprint/printerclient.py deleted file mode 100644 index 35189ee..0000000 --- a/niimprint/printerclient.py +++ /dev/null @@ -1,195 +0,0 @@ -import niimbotpacket -import socket -import struct -import time -import enum - -class InfoEnum(enum.IntEnum): - DENSITY = 1 - PRINTSPEED = 2 - LABELTYPE = 3 - LANGUAGETYPE = 6 - AUTOSHUTDOWNTIME = 7 - DEVICETYPE = 8 - SOFTVERSION = 9 - BATTERY = 10 - DEVICESERIAL = 11 - HARDVERSION = 12 - -class RequestCodeEnum(enum.IntEnum): - GET_INFO = 64 - GET_RFID = 26 - HEARTBEAT = 220 - SET_LABEL_TYPE = 35 - SET_LABEL_DENSITY = 33 - START_PRINT = 1 - END_PRINT = 243 - START_PAGE_PRINT = 3 - END_PAGE_PRINT = 227 - ALLOW_PRINT_CLEAR = 32 - SET_DIMENSION = 19 - SET_QUANTITY = 21 - GET_PRINT_STATUS = 163 - -_packet_to_int = lambda x: int.from_bytes(x.data, 'big') - -# TODO REMOVE MAGIC NUMBER -class PrinterClient: - def __init__(self, address): - self._sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) - self._sock.connect((address, 1)) - self._packetbuf = bytearray() - - def _recv(self): - packets = [] - self._packetbuf.extend(self._sock.recv(1024)) - while len(self._packetbuf) > 4: - pkt_len = self._packetbuf[3] + 7 - if len(self._packetbuf) >= pkt_len: - packet = niimbotpacket.NiimbotPacket.from_bytes(self._packetbuf[:pkt_len]) - # print('recv:',packet) - packets.append(packet) - del self._packetbuf[:pkt_len] - return packets - - def _send(self, packet): - # print('send:',packet) - self._sock.send(packet.to_bytes()) - - def _transceive(self, reqcode, data, respoffset=1): - respcode = respoffset + reqcode - self._send(niimbotpacket.NiimbotPacket(reqcode, data)) - resp = None - for _ in range(6): - for packet in self._recv(): - if packet.type == 219: - raise ValueError - elif packet.type == 0: - raise NotImplementedError - elif packet.type == respcode: - resp = packet - if resp: - return resp - time.sleep(0.1) - return resp - - def get_info(self, key): - if packet := self._transceive(RequestCodeEnum.GET_INFO, bytes((key,)), key): - match key: - case InfoEnum.DEVICESERIAL: - return packet.data.hex() - case InfoEnum.SOFTVERSION: - return _packet_to_int(packet) / 100 - case InfoEnum.HARDVERSION: - return _packet_to_int(packet) / 100 - case _: - return _packet_to_int(packet) - else: - return None - - def get_rfid(self): - packet = self._transceive(RequestCodeEnum.GET_RFID, b'\x01') - data = packet.data - - if data[0] == 0: - return None - uuid = data[0:8].hex() - idx = 8 - - barcode_len = data[idx] - idx += 1 - barcode = data[idx:idx+barcode_len].decode() - - idx += barcode_len - serial_len = data[idx] - idx += 1 - serial = data[idx:idx+serial_len].decode() - - idx += serial_len - total_len, used_len, type_ = struct.unpack('>HHB', data[idx:]) - return { - 'uuid': uuid, - 'barcode': barcode, - 'serial': serial, - 'used_len': used_len, - 'total_len': total_len, - 'type': type_ - } - - def heartbeat(self): - packet = self._transceive(RequestCodeEnum.HEARTBEAT, b'\x01') - closingstate = None - powerlevel = None - paperstate = None - rfidreadstate = None - - match len(packet.data): - case 20: - paperstate = packet.data[18] - rfidreadstate = packet.data[19] - case 13: - closingstate = packet.data[9] - powerlevel = packet.data[10] - paperstate = packet.data[11] - rfidreadstate = packet.data[12] - case 19: - closingstate = packet.data[15] - powerlevel = packet.data[16] - paperstate = packet.data[17] - rfidreadstate = packet.data[18] - case 10: - closingstate = packet.data[8] - powerlevel = packet.data[9] - rfidreadstate = packet.data[8] - case 9: - closingstate = packet.data[8] - - return { - 'closingstate': closingstate, - 'powerlevel': powerlevel, - 'paperstate': paperstate, - 'rfidreadstate': rfidreadstate - } - - def set_label_type(self, n): - assert 1 <= n <= 3 - packet = self._transceive(RequestCodeEnum.SET_LABEL_TYPE, bytes((n,)), 16) - return bool(packet.data[0]) - - def set_label_density(self, n): - assert 1 <= n <= 3 - packet = self._transceive(RequestCodeEnum.SET_LABEL_DENSITY, bytes((n,)), 16) - return bool(packet.data[0]) - - def start_print(self): - packet = self._transceive(RequestCodeEnum.START_PRINT, b'\x01') - return bool(packet.data[0]) - - def end_print(self): - packet = self._transceive(RequestCodeEnum.END_PRINT, b'\x01') - return bool(packet.data[0]) - - def start_page_print(self): - packet = self._transceive(RequestCodeEnum.START_PAGE_PRINT, b'\x01') - return bool(packet.data[0]) - - def end_page_print(self): - packet = self._transceive(RequestCodeEnum.END_PAGE_PRINT, b'\x01') - return bool(packet.data[0]) - - def allow_print_clear(self): - packet = self._transceive(RequestCodeEnum.ALLOW_PRINT_CLEAR, b'\x01', 16) - return bool(packet.data[0]) - - def set_dimension(self, w, h): - packet = self._transceive(RequestCodeEnum.SET_DIMENSION, struct.pack('>HH', w, h)) - return bool(packet.data[0]) - - def set_quantity(self, n): - packet = self._transceive(RequestCodeEnum.SET_QUANTITY, struct.pack('>H', n)) - return bool(packet.data[0]) - - def get_print_status(self): - packet = self._transceive(RequestCodeEnum.GET_PRINT_STATUS, b'\x01', 16) - page, progress1, progress2 = struct.unpack('>HBB', packet.data) - return {'page': page, 'progress1': progress1, 'progress2': progress2} diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c700401 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,112 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "pillow" +version = "10.1.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "Pillow-10.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106"}, + {file = "Pillow-10.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db"}, + {file = "Pillow-10.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f"}, + {file = "Pillow-10.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818"}, + {file = "Pillow-10.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57"}, + {file = "Pillow-10.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7"}, + {file = "Pillow-10.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061"}, + {file = "Pillow-10.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262"}, + {file = "Pillow-10.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992"}, + {file = "Pillow-10.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a"}, + {file = "Pillow-10.1.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b"}, + {file = "Pillow-10.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651"}, + {file = "Pillow-10.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b"}, + {file = "Pillow-10.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f"}, + {file = "Pillow-10.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996"}, + {file = "Pillow-10.1.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:fe1e26e1ffc38be097f0ba1d0d07fcade2bcfd1d023cda5b29935ae8052bd793"}, + {file = "Pillow-10.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7a7e3daa202beb61821c06d2517428e8e7c1aab08943e92ec9e5755c2fc9ba5e"}, + {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24fadc71218ad2b8ffe437b54876c9382b4a29e030a05a9879f615091f42ffc2"}, + {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1d323703cfdac2036af05191b969b910d8f115cf53093125e4058f62012c9a"}, + {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:912e3812a1dbbc834da2b32299b124b5ddcb664ed354916fd1ed6f193f0e2d01"}, + {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7dbaa3c7de82ef37e7708521be41db5565004258ca76945ad74a8e998c30af8d"}, + {file = "Pillow-10.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9d7bc666bd8c5a4225e7ac71f2f9d12466ec555e89092728ea0f5c0c2422ea80"}, + {file = "Pillow-10.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baada14941c83079bf84c037e2d8b7506ce201e92e3d2fa0d1303507a8538212"}, + {file = "Pillow-10.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:2ef6721c97894a7aa77723740a09547197533146fba8355e86d6d9a4a1056b14"}, + {file = "Pillow-10.1.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0a026c188be3b443916179f5d04548092e253beb0c3e2ee0a4e2cdad72f66099"}, + {file = "Pillow-10.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:04f6f6149f266a100374ca3cc368b67fb27c4af9f1cc8cb6306d849dcdf12616"}, + {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb40c011447712d2e19cc261c82655f75f32cb724788df315ed992a4d65696bb"}, + {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a8413794b4ad9719346cd9306118450b7b00d9a15846451549314a58ac42219"}, + {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c9aeea7b63edb7884b031a35305629a7593272b54f429a9869a4f63a1bf04c34"}, + {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b4005fee46ed9be0b8fb42be0c20e79411533d1fd58edabebc0dd24626882cfd"}, + {file = "Pillow-10.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0152565c6aa6ebbfb1e5d8624140a440f2b99bf7afaafbdbf6430426497f28"}, + {file = "Pillow-10.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d921bc90b1defa55c9917ca6b6b71430e4286fc9e44c55ead78ca1a9f9eba5f2"}, + {file = "Pillow-10.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfe96560c6ce2f4c07d6647af2d0f3c54cc33289894ebd88cfbb3bcd5391e256"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f"}, + {file = "Pillow-10.1.0.tar.gz", hash = "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +optional = false +python-versions = "*" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] + +[metadata] +lock-version = "2.0" +python-versions = "~3.11" +content-hash = "cac430413d223c6dc9dc0c27f33322b70a134eb2ff5709934235a261451e419c" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fbebee9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "niimprint" +version = "0.1.0" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "~3.11" +pyserial = "^3.5" +pillow = "^10.1.0" +click = "^8.1.7" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["I", "F", "E"] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f2e6fae --- /dev/null +++ b/readme.md @@ -0,0 +1,52 @@ +# `niimprint` — Experimental Niimbot Printer Client + +**Fork changelog & differences from original version:** + +- Tested on Niimbot B21 and Python 3.11. Niimbot D11 support may be broken! +- Added transport abstraction: switch between bluetooth and USB (serial) +- Switched to [click](https://click.palletsprojects.com/) CLI library instead of argparse +- Integrated [pyproject.toml](https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/) and [poetry](https://python-poetry.org) +- Integrated [pre-commit](https://pre-commit.com/) and [ruff](https://docs.astral.sh/ruff/), re-formatted all files +- Miscellaneous refactoring / file renaming / etc. + +## Installation + +Recommended method is to use [poetry](https://python-poetry.org) and install with `poetry install`. However `requirements.txt` is also provided for convenience. Project is tested on Python 3.11, but should work on other versions. + +## Usage + +``` +$ python niimprint --help + +Usage: niimprint [OPTIONS] + +Options: + -m, --model [b21|d11] Niimbot printer model [default: b21] + -c, --conn [usb|bluetooth] Connection type [default: usb] + -a, --addr TEXT Bluetooth MAC address OR serial device path + -d, --density INTEGER RANGE Print density [default: 5; 1<=x<=5] + -i, --image PATH Image path [required] + --help Show this message and exit. +``` + +## Examples + +**B21, USB connection, 30x15 mm (240x120 px) label.** On linux, you can try to omit `--addr` option and let the script to auto-detect the serial port (it will fail if there're multiple available ports). On windows, serial ports will be named like `COM1`, `COM2` etc. (check in device manager). + +``` +python niimprint --addr /dev/ttyACM0 --image examples/B21_30x15mm_240x120px.png +``` + +**B21, bluetooth connection:** untested (todo). + +**D11:** completly untested, however original fork supports it. If you have D11 at hand and willing to test, please open an issue! + +## Image vs Label size + +### Niimbot B21 + +According to my observations, B21 has **8 pixels per mm** resolution. But there's one catch: printer specs say it supports up to 50mm-wide labels (which sould translate to `50 * 8 = 400` pixels). However trying to print anything larger than `384` pixels resulted in error. My guess it's the actual hardware limit, which is "almost equal" to stated 50 mm. + +### Niimbot D11 + +??? diff --git a/requirements.txt b/requirements.txt index 0799398..0964750 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -Pillow >= 8.0 +click==8.1.7 +pillow==10.1.0 +pyserial==3.5