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:
Alex Cube
2026-10-21 16:56:45 +03:00
co-authored by Claude Opus 5.5
commit 9e06f3ebd0
12 changed files with 765 additions and 0 deletions
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
"""LX-D02 thermal printer: service lxd02.print_text.
Jobs go to a persistent queue (.storage/lxd02). If the printer is off, they wait
and print when it shows up in Bluetooth again.
configuration.yaml:
lxd02:
mac: AA:BB:CC:DD:EE:FF
"""
import asyncio
from datetime import timedelta
import logging
from bleak.exc import BleakError
import voluptuous as vol
from bleak_retry_connector import BleakClientWithServiceCache, establish_connection
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.storage import Store
from .printer import print_payloads, render, to_payloads
DOMAIN = "lxd02"
_LOGGER = logging.getLogger(__name__)
RETRY = timedelta(seconds=60)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required("mac"): cv.string})}, extra=vol.ALLOW_EXTRA
)
PRINT_SCHEMA = vol.Schema(
{
vol.Required("text"): cv.string,
vol.Optional("size", default=28): vol.All(vol.Coerce(int), vol.Range(min=10, max=120)),
vol.Optional("density", default=3): vol.All(vol.Coerce(int), vol.Range(min=0, max=7)),
vol.Optional("feed", default=40): vol.All(vol.Coerce(int), vol.Range(min=0, max=500)),
vol.Optional("separator", default=False): cv.boolean,
}
)
async def async_setup(hass: HomeAssistant, config) -> bool:
mac = config[DOMAIN]["mac"].upper()
lock = asyncio.Lock()
store = Store(hass, 1, DOMAIN)
queue = await store.async_load() or []
async def print_job(device, job):
img = await hass.async_add_executor_job(
render, job["text"], job["size"], job["separator"]
)
payloads = to_payloads(img)
try:
client = await establish_connection(BleakClientWithServiceCache, device, "LX-D02")
except BleakError as err:
raise HomeAssistantError(f"LX-D02 connect failed: {err!r}") from err
try:
await print_payloads(client, mac, payloads, job["density"], job["feed"])
except (asyncio.TimeoutError, RuntimeError, BleakError) as err:
raise HomeAssistantError(f"LX-D02 print failed: {err!r}") from err
finally:
await client.disconnect()
async def flush():
async with lock:
while queue:
device = bluetooth.async_ble_device_from_address(hass, mac, connectable=True)
if device is None:
_LOGGER.info("LX-D02 not in range, %d job(s) queued", len(queue))
return
try:
await print_job(device, queue[0])
except HomeAssistantError as err:
_LOGGER.warning("%s; %d job(s) stay queued", err, len(queue))
return
except Exception: # bad job (render etc.): drop it, don't block the queue
_LOGGER.exception("LX-D02 job dropped: %r", queue[0])
queue.pop(0)
await store.async_save(queue)
async def print_text(call: ServiceCall) -> None:
queue.append(dict(call.data))
await store.async_save(queue)
await flush()
@callback
def seen(service_info, change) -> None:
if queue and not lock.locked():
hass.async_create_task(flush())
# HA calls `seen` only when the advert changes or the printer comes back after
# being marked unavailable, not on every packet. So a failed attempt (or a replay
# of stale history right after HA restart) is never retried by adverts alone:
# the timer retries while the queue is not empty.
async_track_time_interval(hass, callback(lambda _: seen(None, None)), RETRY)
bluetooth.async_register_callback(
hass,
seen,
bluetooth.BluetoothCallbackMatcher(address=mac, connectable=True),
bluetooth.BluetoothScanningMode.ACTIVE,
)
hass.services.async_register(DOMAIN, "print_text", print_text, schema=PRINT_SCHEMA)
return True
+10
View File
@@ -0,0 +1,10 @@
{
"domain": "lxd02",
"name": "LX-D02 Thermal Printer",
"version": "0.2.0",
"documentation": "https://git.08h.ru/alex/lx-d02",
"dependencies": ["bluetooth"],
"requirements": [],
"codeowners": [],
"iot_class": "local_push"
}
+143
View File
@@ -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")
+38
View File
@@ -0,0 +1,38 @@
print_text:
name: Print text
description: Print text on the LX-D02 thermal printer.
fields:
text:
name: Text
required: true
example: "Temperature: 21.5 °C"
selector:
text:
multiline: true
size:
name: Font size
default: 28
selector:
number:
min: 10
max: 120
density:
name: Density
default: 3
selector:
number:
min: 0
max: 7
separator:
name: Separator line
description: Draw a horizontal line under the text.
default: false
selector:
boolean:
feed:
name: Feed after (rows of 2 dots)
default: 40
selector:
number:
min: 0
max: 500