Fix text/image alignment, add dithering, extend Classic BT to Windows
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Fichero / D11s thermal label printer - BLE interface.
|
||||
Fichero / D11s thermal label printer - BLE + Classic Bluetooth interface.
|
||||
|
||||
Protocol reverse-engineered from decompiled Fichero APK (com.lj.fichero).
|
||||
Device class: AiYinNormalDevice (LuckPrinter SDK)
|
||||
@@ -7,11 +7,22 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
||||
|
||||
# --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) ---
|
||||
|
||||
_RFCOMM_AVAILABLE = False
|
||||
if sys.platform in ("linux", "win32"):
|
||||
import socket as _socket
|
||||
|
||||
_RFCOMM_AVAILABLE = hasattr(_socket, "AF_BLUETOOTH")
|
||||
|
||||
RFCOMM_CHANNEL = 1
|
||||
|
||||
# --- BLE identifiers ---
|
||||
|
||||
PRINTER_NAME_PREFIXES = ("FICHERO", "D11s_")
|
||||
@@ -24,7 +35,8 @@ NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
PRINTHEAD_PX = 96
|
||||
BYTES_PER_ROW = PRINTHEAD_PX // 8 # 12
|
||||
CHUNK_SIZE = 200
|
||||
CHUNK_SIZE_BLE = 200 # BLE MTU-limited
|
||||
CHUNK_SIZE_CLASSIC = 16384 # from decompiled app (C1703d.java), stream-based
|
||||
|
||||
# --- Paper types for 10 FF 84 nn ---
|
||||
|
||||
@@ -111,6 +123,81 @@ class PrinterStatus:
|
||||
return not (self.cover_open or self.no_paper or self.overheated)
|
||||
|
||||
|
||||
# --- RFCOMM client (duck-types the BleakClient interface) ---
|
||||
|
||||
|
||||
class RFCOMMClient:
|
||||
"""Classic Bluetooth (RFCOMM) transport. Linux + Windows (Python 3.9+).
|
||||
|
||||
Implements the same async context manager + write_gatt_char/start_notify
|
||||
interface that PrinterClient expects from BleakClient. Zero dependencies
|
||||
beyond stdlib.
|
||||
"""
|
||||
|
||||
is_classic = True # transport marker for PrinterClient chunk sizing
|
||||
|
||||
def __init__(self, address: str, channel: int = RFCOMM_CHANNEL):
|
||||
self._address = address
|
||||
self._channel = channel
|
||||
self._sock: "_socket.socket | None" = None
|
||||
self._reader_task: asyncio.Task | None = None
|
||||
|
||||
async def __aenter__(self) -> "RFCOMMClient":
|
||||
if not _RFCOMM_AVAILABLE:
|
||||
raise PrinterError(
|
||||
"RFCOMM transport requires socket.AF_BLUETOOTH "
|
||||
"(Linux with BlueZ, or Windows with Python 3.9+). "
|
||||
"Not available on this platform."
|
||||
)
|
||||
import socket as _socket
|
||||
|
||||
sock = _socket.socket(
|
||||
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
||||
)
|
||||
sock.setblocking(False)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
loop.sock_connect(sock, (self._address, self._channel)),
|
||||
timeout=10.0,
|
||||
)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
self._sock = sock
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
if self._reader_task is not None:
|
||||
self._reader_task.cancel()
|
||||
try:
|
||||
await self._reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._reader_task = None
|
||||
if self._sock is not None:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
|
||||
async def write_gatt_char(self, _uuid: str, data: bytes, response: bool = False) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.sock_sendall(self._sock, data)
|
||||
|
||||
async def start_notify(self, _uuid: str, callback) -> None:
|
||||
self._reader_task = asyncio.create_task(self._reader_loop(callback))
|
||||
|
||||
async def _reader_loop(self, callback) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
try:
|
||||
data = await loop.sock_recv(self._sock, 1024)
|
||||
except (OSError, asyncio.CancelledError):
|
||||
return
|
||||
if not data:
|
||||
return
|
||||
callback(None, bytearray(data))
|
||||
|
||||
|
||||
# --- Client ---
|
||||
|
||||
|
||||
@@ -120,6 +207,7 @@ class PrinterClient:
|
||||
self._buf = bytearray()
|
||||
self._event = asyncio.Event()
|
||||
self._lock = asyncio.Lock()
|
||||
self._is_classic = getattr(client, "is_classic", False)
|
||||
|
||||
def _on_notify(self, _char: BleakGATTCharacteristic, data: bytearray) -> None:
|
||||
self._buf.extend(data)
|
||||
@@ -140,13 +228,18 @@ class PrinterClient:
|
||||
await asyncio.sleep(DELAY_NOTIFY_EXTRA)
|
||||
except asyncio.TimeoutError:
|
||||
raise PrinterTimeout(f"No response within {timeout}s")
|
||||
return bytes(self._buf)
|
||||
return bytes(self._buf)
|
||||
|
||||
async def send_chunked(self, data: bytes, chunk_size: int = CHUNK_SIZE) -> None:
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i : i + chunk_size]
|
||||
await self.client.write_gatt_char(WRITE_UUID, chunk, response=False)
|
||||
await asyncio.sleep(DELAY_CHUNK_GAP)
|
||||
async def send_chunked(self, data: bytes, chunk_size: int | None = None) -> None:
|
||||
if chunk_size is None:
|
||||
chunk_size = CHUNK_SIZE_CLASSIC if self._is_classic else CHUNK_SIZE_BLE
|
||||
delay = 0 if self._is_classic else DELAY_CHUNK_GAP
|
||||
async with self._lock:
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i : i + chunk_size]
|
||||
await self.client.write_gatt_char(WRITE_UUID, chunk, response=False)
|
||||
if delay:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# --- Info commands (all tested and confirmed on D11s fw 2.4.6) ---
|
||||
|
||||
@@ -266,10 +359,22 @@ class PrinterClient:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect(address: str | None = None) -> AsyncGenerator[PrinterClient, None]:
|
||||
async def connect(
|
||||
address: str | None = None,
|
||||
classic: bool = False,
|
||||
channel: int = RFCOMM_CHANNEL,
|
||||
) -> AsyncGenerator[PrinterClient, None]:
|
||||
"""Discover printer, connect, and yield a ready PrinterClient."""
|
||||
addr = address or await find_printer()
|
||||
async with BleakClient(addr) as client:
|
||||
pc = PrinterClient(client)
|
||||
await pc.start()
|
||||
yield pc
|
||||
if classic:
|
||||
if not address:
|
||||
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
||||
async with RFCOMMClient(address, channel) as client:
|
||||
pc = PrinterClient(client)
|
||||
await pc.start()
|
||||
yield pc
|
||||
else:
|
||||
addr = address or await find_printer()
|
||||
async with BleakClient(addr) as client:
|
||||
pc = PrinterClient(client)
|
||||
await pc.start()
|
||||
yield pc
|
||||
|
||||
Reference in New Issue
Block a user