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
+60
View File
@@ -0,0 +1,60 @@
# Adds the "LX-D02" printer to Windows, starts print_server.py at every logon and adds
# "Print on LX-D02" for pictures to the Explorer menu and to "Send to".
# Needs the POS-58 driver (stock driver for 58 mm receipt printers), see README.
#
# Run in PowerShell as administrator: .\install.ps1 (printer found by name)
# .\install.ps1 AA:BB:CC:DD:EE:FF
# Remove: .\install.ps1 -Uninstall
param([string]$Mac = "", [switch]$Uninstall)
$ErrorActionPreference = "Stop"
$Name = "LX-D02"
$Port = "LX-D02-9100"
$Task = "LX-D02 print server"
$Root = Split-Path $PSScriptRoot
$Menu = "HKCU:\Software\Classes\SystemFileAssociations\image\shell\LXD02"
$SendTo = Join-Path ([Environment]::GetFolderPath("SendTo")) "LX-D02.lnk"
# Clean up first, so running it again simply reinstalls
Stop-ScheduledTask -TaskName $Task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $Task -Confirm:$false -ErrorAction SilentlyContinue
Get-CimInstance Win32_Process -Filter "Name like 'python%.exe'" |
Where-Object { $_.CommandLine -like "*print_server.py*" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
Remove-Printer -Name $Name -ErrorAction SilentlyContinue
Remove-PrinterPort -Name $Port -ErrorAction SilentlyContinue
Remove-Item $Menu -Recurse -ErrorAction SilentlyContinue
Remove-Item $SendTo -ErrorAction SilentlyContinue
if ($Uninstall) { "Removed."; return }
$driver = Get-PrinterDriver | Where-Object Name -like "POS-58*" | Select-Object -First 1
if (-not $driver) { throw "POS-58 driver is not installed. See README: 'Driver'." }
$py = (Get-Command python.exe).Source
$pyw = (Get-Command pythonw.exe).Source
# The printer: the POS-58 driver sends ESC/POS to our server instead of a COM port
Add-PrinterPort -Name $Port -PrinterHostAddress 127.0.0.1 -PortNumber 9100
Add-Printer -Name $Name -DriverName $driver.Name -PortName $Port
# The server, started at logon
$action = New-ScheduledTaskAction -Execute $pyw -Argument "`"$PSScriptRoot\print_server.py`" $Mac" -WorkingDirectory $PSScriptRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $Task -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $Task
# Pictures: right click -> "Print on LX-D02", or several at once via "Send to"
$cmd = "`"$py`" `"$Root\lxd02.py`""
New-Item -Path "$Menu\command" -Force | Out-Null
Set-ItemProperty -Path $Menu -Name "(default)" -Value "Print on LX-D02"
Set-ItemProperty -Path $Menu -Name "Icon" -Value "$env:SystemRoot\System32\imageres.dll,46"
Set-ItemProperty -Path $Menu -Name "MultiSelectModel" -Value "Single" # one Bluetooth connection at a time
Set-ItemProperty -Path "$Menu\command" -Name "(default)" -Value "$cmd `"%1`""
$link = (New-Object -ComObject WScript.Shell).CreateShortcut($SendTo)
$link.TargetPath = $py
$link.Arguments = "`"$Root\lxd02.py`""
$link.WorkingDirectory = $Root
$link.IconLocation = "$env:SystemRoot\System32\imageres.dll,46"
$link.Save()
"Done: printer '$Name' (driver '$($driver.Name)'), print server, Explorer menu."
"Log: $PSScriptRoot\print_server.log"
+123
View File
@@ -0,0 +1,123 @@
"""LX-D02 as a regular Windows printer.
Windows prints to the "LX-D02" printer: the stock POS-58 driver for 58 mm receipt
printers, on a TCP port 127.0.0.1:9100 (see install.ps1). The driver turns the page into
ESC/POS raster; this server takes it from the port and prints it over Bluetooth.
Usage: python print_server.py [MAC] (without MAC the printer is found by name)
python print_server.py --test FILE (saved job -> FILE.png, no printing)
python print_server.py --selftest
"""
import asyncio
import logging
import queue
import socketserver
import sys
import threading
from pathlib import Path
from PIL import Image, ImageOps
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lxd02 import WIDTH, send # noqa: E402
PORT = 9100
MAX_GAP = 48 # dots: longer white gaps are cut down to this (6 mm)
LAST_JOB = Path(__file__).with_name("last_job.bin")
log = logging.getLogger("lx-d02")
def render(data):
"""ESC/POS from the POS-58 driver -> 1-bit image WIDTH wide, ready to print.
The driver sends the page as GS v 0 raster bands. Its ESC J feeds between the bands
only move the paper by the band height, so the bands are just stacked."""
rows, pos = [], 0
while True:
pos = data.find(b"\x1dv0", pos)
if pos < 0 or pos + 8 > len(data):
break
w = data[pos + 4] | data[pos + 5] << 8
h = data[pos + 6] | data[pos + 7] << 8
band = data[pos + 8:pos + 8 + w * h]
rows += [band[r * w:(r + 1) * w].ljust(WIDTH // 8, b"\0")[:WIDTH // 8] for r in range(h)]
pos += 8 + w * h
if not rows:
raise ValueError(f"no ESC/POS raster in the job: {data[:16]!r} (is the driver POS-58?)")
img = ImageOps.invert(Image.frombytes("1", (WIDTH, len(rows)), b"".join(rows)).convert("L"))
box = ImageOps.invert(img).getbbox()
if not box:
raise ValueError("blank page")
# Apps keep their page margins: drop the left one and the white above and below
shift = max(0, box[0] - 4)
img = img.crop((shift, max(0, box[1] - 4), WIDTH + shift, min(img.height, box[3] + 4)))
img.paste(255, (WIDTH - shift, 0, WIDTH, img.height)) # the crop past the right edge is black
return squeeze(img).convert("1")
def squeeze(img, gap=MAX_GAP):
"""Shorten white gaps longer than `gap` rows: page layouts put the footer a whole
page below the text, and the tape is not a page."""
raw = img.tobytes()
keep, blank = [], 0
for y in range(img.height):
row = raw[y * img.width:(y + 1) * img.width]
blank = blank + 1 if min(row) == 255 else 0
if blank <= gap:
keep.append(row)
return Image.frombytes("L", (img.width, len(keep)), b"".join(keep))
class Job(socketserver.BaseRequestHandler):
jobs = queue.Queue()
def handle(self):
chunks = []
while True:
chunk = self.request.recv(65536)
if not chunk:
break
chunks.append(chunk)
if chunks: # an empty connection is Windows checking the port
self.jobs.put(b"".join(chunks))
def printer_loop(mac):
"""One job at a time: the printer takes a single Bluetooth connection."""
while True:
data = Job.jobs.get()
LAST_JOB.write_bytes(data) # for debugging a strange print: --test last_job.bin
try:
img = render(data)
log.info("job: %d bytes, %d dots long", len(data), img.height)
asyncio.run(send(img, mac))
log.info("printed")
except Exception:
log.exception("job failed")
def main(mac):
threading.Thread(target=printer_loop, args=(mac,), daemon=True).start()
server = socketserver.ThreadingTCPServer(("127.0.0.1", PORT), Job)
log.info("waiting for jobs on 127.0.0.1:%d", PORT)
server.serve_forever()
if __name__ == "__main__":
handlers = [logging.FileHandler(Path(__file__).with_name("print_server.log"), encoding="utf-8")]
if sys.stderr: # None under pythonw
handlers.append(logging.StreamHandler())
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", handlers=handlers)
if sys.argv[1:2] == ["--selftest"]:
# one blank row, then one row with a single dot at x = 7: comes out shifted left
job = b"\x1b@\x1dv0\x00" + bytes([48, 0, 2, 0]) + bytes(48) + b"\x01" + bytes(47) + b"\x1bJ\x15"
img = render(job)
assert img.width == WIDTH and img.getpixel((4, 1)) == 0 and img.getpixel((7, 1)) == 255
assert squeeze(Image.new("L", (8, 100), 255), 10).height == 10
print("ok")
elif sys.argv[1:2] == ["--test"]:
src = Path(sys.argv[2])
render(src.read_bytes()).save(src.with_suffix(".png"))
print("ok", src.with_suffix(".png"))
else:
main(sys.argv[1] if len(sys.argv) > 1 else None)