LX-D02 BLE thermal printer: PC, Windows printer, Home Assistant
- printer.py: protocol with flow control (pause / resend / done) - lxd02.py: print text or pictures from the command line - windows/: POS-58 driver + local ESC/POS server = a regular Windows printer, Explorer menu for pictures - custom_components/lxd02: Home Assistant integration with a persistent queue Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user