"""LX-D02 (FunnyPrint) protocol. No Home Assistant imports: also used by ../../lxd02.py. Protocol from github.com/ValdikSS/printer-driver-funnyprint. """ import asyncio from pathlib import Path from PIL import Image, ImageDraw, ImageFont WRITE = "0000ffe1-0000-1000-8000-00805f9b34fb" NOTIFY = "0000ffe2-0000-1000-8000-00805f9b34fb" WIDTH = 384 # dots, 48 bytes per row FONT = Path(__file__).with_name("DejaVuSans.ttf") def crc16(data): crc = 0 for b in data: for i in range(8): if ((crc >> 15) & 1) ^ ((b >> (7 - i)) & 1): crc = ((crc << 1) & 0xFFFF) ^ 0x1021 else: crc = (crc << 1) & 0xFFFF return crc def handshake_reply(mac): r = crc16(b"\x00" + bytes.fromhex(mac.replace(":", ""))) >> 8 return b"\x5a\x0b" + bytes([r]) * 10 def wrap(text, font, width): out = [] for para in text.split("\n"): line = "" for word in para.split(" "): cand = f"{line} {word}" if line else word if line and font.getlength(cand) > width: out.append(line) line = word else: line = cand out.append(line) return out def render(text, size=28, separator=False): font = ImageFont.truetype(str(FONT), size) lines = wrap(text, font, WIDTH - 8) text_h = (size + 6) * len(lines) + 4 h = text_h + (16 if separator else 0) img = Image.new("1", (WIDTH, h + h % 2), 1) d = ImageDraw.Draw(img) for i, line in enumerate(lines): d.text((4, 2 + i * (size + 6)), line, font=font, fill=0) if separator: d.rectangle((0, text_h + 6, WIDTH - 1, text_h + 9), fill=0) return img def to_payloads(img): """1-bit image, WIDTH wide -> 96-byte payloads, two rows each, 1 = black.""" if img.height % 2: img = img.crop((0, 0, WIDTH, img.height + 1)) # pads with 0 = black img.paste(1, (0, img.height - 1, WIDTH, img.height)) raw = bytes(b ^ 0xFF for b in img.tobytes()) rows = [raw[i:i + 48] for i in range(0, len(raw), 48)] return [rows[i] + rows[i + 1] for i in range(0, len(rows), 2)] async def print_payloads(client, mac, payloads, density=3, feed=40): """Handshake and print on an already connected BleakClient.""" payloads = payloads + [bytes(96)] * feed events = asyncio.Queue() def w(data): return client.write_gatt_char(WRITE, data, response=False) async def wait_for(prefix, timeout=5): while True: data = await asyncio.wait_for(events.get(), timeout) if data[:2] == prefix: return data await client.start_notify(NOTIFY, lambda _, data: events.put_nowait(bytes(data))) await w(b"\x5a\x01" + bytes(10)) # device info await asyncio.sleep(0.5) await w(b"\x5a\x0a" + bytes(10)) # challenge await wait_for(b"\x5a\x0a") await w(handshake_reply(mac)) if (await wait_for(b"\x5a\x0b"))[2] != 1: raise RuntimeError("LX-D02 handshake failed") await w(b"\x5a\x0c" + bytes([max(0, min(7, density))])) await asyncio.sleep(0.3) n = len(payloads) await w(b"\x5a\x04" + n.to_bytes(2, "big") + b"\x00\x00") await asyncio.sleep(0.1) # Flow control as in klimromanyuk/funnyprint: the printer's buffer is small. # 5a08 = "pause, buffer full"; 5a05 + row number = "lost rows, resend from here"; # 5a06 = "done". Without this, long prints (pictures) come out cut short. cur, paused, rests = 0, False, 0 try: while True: if paused or cur >= n: data = await asyncio.wait_for(events.get(), 90 if paused else max(60, n * 0.5)) elif not events.empty(): data = events.get_nowait() else: await w(b"\x55" + cur.to_bytes(2, "big") + payloads[cur] + b"\x00") cur += 1 await asyncio.sleep(0.025) continue kind = data[:2] if kind == b"\x5a\x06": return if kind == b"\x5a\x08": paused = True elif kind == b"\x5a\x05": lost = int.from_bytes(data[2:4], "big") if paused: # let the printer print out its buffer, then resend rests += 1 await asyncio.sleep(min(8.0, 2.0 + rests * 0.5)) while not events.empty(): more = events.get_nowait() if more[:2] == b"\x5a\x06": return if more[:2] == b"\x5a\x05": lost = int.from_bytes(more[2:4], "big") paused = False cur = lost finally: await w(b"\x5a\x04" + n.to_bytes(2, "big") + b"\x01\x00") if __name__ == "__main__": assert handshake_reply("00:00:00:00:00:00") == b"\x5a\x0b" + bytes(10) assert to_payloads(Image.new("1", (WIDTH, 2), 0)) == [b"\xff" * 96] assert to_payloads(Image.new("1", (WIDTH, 1), 0)) == [b"\xff" * 48 + bytes(48)] assert render("x", separator=True).height == render("x").height + 16 assert len(wrap("a " * 200, ImageFont.truetype(str(FONT), 28), WIDTH)) > 1 print("ok")