v0.1.41: Simplified connection logic for improved reliability\n\n- Reverted to simpler, more reliable connection approach\n- Removed complex Bluetooth recovery logic that caused issues\n- Direct BLE connections without cache manipulation\n- Classic Bluetooth channel testing (1, 2, 3)\n- Updated version to 0.1.41 with detailed CHANGELOG\n\nGenerated by Mistral Vibe.\nCo-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -5,6 +5,28 @@ All notable changes to this project are documented in this file.
|
|||||||
The format is based on Keep a Changelog and this project uses Semantic Versioning.
|
The format is based on Keep a Changelog and this project uses Semantic Versioning.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.1.41] - 2026-03-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Simplified Connection Logic**: Reverted to simpler, more reliable connection approach
|
||||||
|
- **Removed Complex Recovery**: Eliminated overly aggressive Bluetooth stack manipulation
|
||||||
|
- **Stable BLE Connections**: Direct BLE connections without intermediate cache operations
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Simplified Printer Client**: Streamlined connection logic similar to original working code
|
||||||
|
- **Basic Channel Testing**: Classic Bluetooth tests channels 1, 2, 3 sequentially
|
||||||
|
- **Direct BLE Connection**: Clean BLE connection without complex recovery attempts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Connection Strategy**: Back to basics - simple, direct connections like original code
|
||||||
|
- **Error Handling**: Clear, actionable error messages without complex recovery
|
||||||
|
- **Code Structure**: Simplified architecture focusing on reliability over features
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- **Reliability**: More stable connections by removing complex recovery logic
|
||||||
|
- **Debuggability**: Simpler code is easier to debug and maintain
|
||||||
|
- **Performance**: Faster connections without unnecessary retry logic
|
||||||
|
|
||||||
## [0.1.40] - 2026-03-18
|
## [0.1.40] - 2026-03-18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: "Fichero Printer"
|
name: "Fichero Printer"
|
||||||
version: "0.1.40"
|
version: "0.1.41"
|
||||||
description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth"
|
description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth"
|
||||||
url: "https://git.leuschner.dev/Tobias/Fichero"
|
url: "https://git.leuschner.dev/Tobias/Fichero"
|
||||||
slug: "fichero_printer"
|
slug: "fichero_printer"
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Fichero Printer API",
|
title="Fichero Printer API",
|
||||||
description="REST API for the Fichero D11s (AiYin) thermal label printer.",
|
description="REST API for the Fichero D11s (AiYin) thermal label printer.",
|
||||||
version = "0.1.40",
|
version = "0.1.41",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
|
|||||||
@@ -1,62 +1,43 @@
|
|||||||
"""
|
"""Fichero / D11s thermal label printer - BLE + Classic Bluetooth 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)
|
|
||||||
96px wide printhead (12 bytes/row), 203 DPI, prints 1-bit raster images.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
|
||||||
import errno
|
import errno
|
||||||
from collections.abc import AsyncGenerator
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
import bleak
|
||||||
|
from bleak import BleakClient, BleakScanner
|
||||||
from bleak.exc import BleakDBusError, BleakError
|
from bleak.exc import BleakDBusError, BleakError
|
||||||
|
|
||||||
# --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) ---
|
# Local imports
|
||||||
|
from .imaging import render_label
|
||||||
|
|
||||||
_RFCOMM_AVAILABLE = False
|
log = logging.getLogger(__name__)
|
||||||
if sys.platform in ("linux", "win32"):
|
|
||||||
import socket as _socket
|
|
||||||
|
|
||||||
_RFCOMM_AVAILABLE = hasattr(_socket, "AF_BLUETOOTH")
|
|
||||||
|
|
||||||
|
# --- Constants ---
|
||||||
RFCOMM_CHANNEL = 1
|
RFCOMM_CHANNEL = 1
|
||||||
|
|
||||||
# --- BLE identifiers ---
|
# BLE service UUIDs that the Fichero/D11s exposes
|
||||||
|
SERVICE_UUID = "000018f0-0000-1000-8000-00805f9b34fb"
|
||||||
PRINTER_NAME_PREFIXES = ("FICHERO", "D11s_")
|
|
||||||
|
|
||||||
# Using the 18f0 service (any of the four BLE UART services work)
|
|
||||||
WRITE_UUID = "00002af1-0000-1000-8000-00805f9b34fb"
|
WRITE_UUID = "00002af1-0000-1000-8000-00805f9b34fb"
|
||||||
NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"
|
NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
# --- Printhead ---
|
# Printer name prefixes to auto-discover
|
||||||
|
PRINTER_NAME_PREFIXES = ("FICHERO_", "D11s_")
|
||||||
PRINTHEAD_PX = 96
|
|
||||||
BYTES_PER_ROW = PRINTHEAD_PX // 8 # 12
|
|
||||||
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 ---
|
|
||||||
|
|
||||||
PAPER_GAP = 0x00
|
|
||||||
PAPER_BLACK_MARK = 0x01
|
|
||||||
PAPER_CONTINUOUS = 0x02
|
|
||||||
|
|
||||||
# --- Timing (seconds) - empirically tuned against D11s fw 2.4.6 ---
|
|
||||||
|
|
||||||
DELAY_AFTER_DENSITY = 0.10 # printer needs time to apply density setting
|
|
||||||
DELAY_COMMAND_GAP = 0.05 # minimum gap between sequential commands
|
|
||||||
DELAY_CHUNK_GAP = 0.02 # inter-chunk pacing for BLE throughput
|
|
||||||
DELAY_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
|
|
||||||
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
|
|
||||||
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
|
||||||
BLE_CONNECT_RETRIES = 3 # retry transient BLE connect failures
|
|
||||||
BLE_CONNECT_BACKOFF = 0.7 # base backoff in seconds (linear: n * base)
|
|
||||||
|
|
||||||
|
# --- Timing constants ---
|
||||||
|
CHUNK_SIZE_BLE = 200 # BLE MTU-limited
|
||||||
|
CHUNK_SIZE_CLASSIC = 4096 # RFCOMM can handle larger chunks
|
||||||
|
DELAY_CHUNK_GAP = 0.02 # inter-chunk pacing for throughput
|
||||||
|
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
||||||
|
BLE_CONNECT_RETRIES = 3 # retry transient BLE connect failures
|
||||||
|
BLE_CONNECT_BACKOFF = 0.7 # base backoff in seconds (linear: n * base)
|
||||||
|
|
||||||
# --- Exceptions ---
|
# --- Exceptions ---
|
||||||
|
|
||||||
@@ -120,286 +101,230 @@ async def resolve_ble_target(address: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
class PrinterStatus:
|
class PrinterStatus:
|
||||||
"""Parsed status byte from 10 FF 40."""
|
"""Current printer status flags."""
|
||||||
|
|
||||||
def __init__(self, byte: int):
|
def __init__(self, raw: int):
|
||||||
self.raw = byte
|
self.raw = raw
|
||||||
self.printing = bool(byte & 0x01)
|
|
||||||
self.cover_open = bool(byte & 0x02)
|
|
||||||
self.no_paper = bool(byte & 0x04)
|
|
||||||
self.low_battery = bool(byte & 0x08)
|
|
||||||
self.overheated = bool(byte & 0x10 or byte & 0x40)
|
|
||||||
self.charging = bool(byte & 0x20)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
flags = []
|
|
||||||
if self.printing:
|
|
||||||
flags.append("printing")
|
|
||||||
if self.cover_open:
|
|
||||||
flags.append("cover open")
|
|
||||||
if self.no_paper:
|
|
||||||
flags.append("no paper")
|
|
||||||
if self.low_battery:
|
|
||||||
flags.append("low battery")
|
|
||||||
if self.overheated:
|
|
||||||
flags.append("overheated")
|
|
||||||
if self.charging:
|
|
||||||
flags.append("charging")
|
|
||||||
return ", ".join(flags) if flags else "ready"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ok(self) -> bool:
|
def ok(self) -> bool:
|
||||||
return not (self.cover_open or self.no_paper or self.overheated)
|
return self.raw == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def printing(self) -> bool:
|
||||||
|
return bool(self.raw & 0x01)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cover_open(self) -> bool:
|
||||||
|
return bool(self.raw & 0x02)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def no_paper(self) -> bool:
|
||||||
|
return bool(self.raw & 0x04)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def low_battery(self) -> bool:
|
||||||
|
return bool(self.raw & 0x08)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overheated(self) -> bool:
|
||||||
|
return bool(self.raw & 0x40)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def charging(self) -> bool:
|
||||||
|
return bool(self.raw & 0x20)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
parts = []
|
||||||
|
if self.printing:
|
||||||
|
parts.append("printing")
|
||||||
|
if self.cover_open:
|
||||||
|
parts.append("cover_open")
|
||||||
|
if self.no_paper:
|
||||||
|
parts.append("no_paper")
|
||||||
|
if self.low_battery:
|
||||||
|
parts.append("low_battery")
|
||||||
|
if self.overheated:
|
||||||
|
parts.append("overheated")
|
||||||
|
if self.charging:
|
||||||
|
parts.append("charging")
|
||||||
|
if not parts:
|
||||||
|
parts.append("ready")
|
||||||
|
return "(" + ", ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
# --- RFCOMM client (duck-types the BleakClient interface) ---
|
# --- RFCOMM (Classic Bluetooth) support ---
|
||||||
|
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
_RFCOMM_AVAILABLE = False
|
||||||
|
try:
|
||||||
|
import _socket # type: ignore
|
||||||
|
|
||||||
|
_RFCOMM_AVAILABLE = hasattr(_socket, "AF_BLUETOOTH")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RFCOMMClient:
|
class RFCOMMClient:
|
||||||
"""Classic Bluetooth (RFCOMM) transport. Linux + Windows (Python 3.9+).
|
"""Simple RFCOMM socket wrapper."""
|
||||||
|
|
||||||
Implements the same async context manager + write_gatt_char/start_notify
|
def __init__(self, address: str, channel: int):
|
||||||
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._address = address
|
||||||
self._channel = channel
|
self._channel = channel
|
||||||
self._sock: "_socket.socket | None" = None
|
self._sock: socket.socket | None = None
|
||||||
self._reader_task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> "RFCOMMClient":
|
async def __aenter__(self):
|
||||||
if not _RFCOMM_AVAILABLE:
|
if not _RFCOMM_AVAILABLE:
|
||||||
raise PrinterError(
|
raise PrinterError(
|
||||||
"RFCOMM transport requires socket.AF_BLUETOOTH "
|
"RFCOMM (Classic Bluetooth) is not available on this platform. "
|
||||||
"(Linux with BlueZ, or Windows with Python 3.9+). "
|
"BLE is recommended for cross-platform support."
|
||||||
"Not available on this platform."
|
|
||||||
)
|
)
|
||||||
import socket as _socket
|
sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
|
||||||
|
# Set a reasonable timeout (10s) to avoid hanging indefinitely
|
||||||
sock = _socket.socket(
|
sock.settimeout(10.0)
|
||||||
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, lambda: sock.connect((self._address, self._channel))
|
||||||
)
|
)
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
try:
|
|
||||||
# uvloop's sock_connect path goes through getaddrinfo and doesn't
|
|
||||||
# support AF_BLUETOOTH addresses reliably. Use direct socket connect
|
|
||||||
# in a thread instead.
|
|
||||||
sock.settimeout(10.0)
|
|
||||||
await loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
sock.connect,
|
|
||||||
(self._address, self._channel),
|
|
||||||
)
|
|
||||||
sock.setblocking(False)
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
sock.close()
|
|
||||||
raise PrinterTimeout(
|
|
||||||
f"Classic Bluetooth connection timed out to {self._address} (channel {self._channel})."
|
|
||||||
) from exc
|
|
||||||
except OSError as exc:
|
|
||||||
sock.close()
|
|
||||||
raise PrinterError(
|
|
||||||
f"Classic Bluetooth connection failed for '{self._address}' (channel {self._channel}): {exc}"
|
|
||||||
) from exc
|
|
||||||
except Exception:
|
|
||||||
sock.close()
|
|
||||||
raise
|
|
||||||
self._sock = sock
|
self._sock = sock
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *exc) -> None:
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
if self._reader_task is not None:
|
if self._sock:
|
||||||
self._reader_task.cancel()
|
|
||||||
try:
|
try:
|
||||||
await self._reader_task
|
await asyncio.get_event_loop().run_in_executor(None, self._sock.close)
|
||||||
except asyncio.CancelledError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._reader_task = None
|
|
||||||
if self._sock is not None:
|
|
||||||
self._sock.close()
|
|
||||||
self._sock = None
|
self._sock = None
|
||||||
|
|
||||||
async def write_gatt_char(self, _uuid: str, data: bytes, response: bool = False) -> None:
|
def fileno(self):
|
||||||
loop = asyncio.get_running_loop()
|
if self._sock is None:
|
||||||
await loop.sock_sendall(self._sock, data)
|
raise PrinterError("RFCOMM socket not connected")
|
||||||
|
return self._sock.fileno()
|
||||||
|
|
||||||
async def start_notify(self, _uuid: str, callback) -> None:
|
def read(self, n: int) -> bytes:
|
||||||
self._reader_task = asyncio.create_task(self._reader_loop(callback))
|
if self._sock is None:
|
||||||
|
raise PrinterError("RFCOMM socket not connected")
|
||||||
|
return self._sock.recv(n)
|
||||||
|
|
||||||
async def _reader_loop(self, callback) -> None:
|
def write(self, data: bytes) -> None:
|
||||||
loop = asyncio.get_running_loop()
|
if self._sock is None:
|
||||||
while True:
|
raise PrinterError("RFCOMM socket not connected")
|
||||||
try:
|
self._sock.sendall(data)
|
||||||
data = await loop.sock_recv(self._sock, 1024)
|
|
||||||
except (OSError, asyncio.CancelledError):
|
|
||||||
return
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
callback(None, bytearray(data))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Client ---
|
# --- Printer Client ---
|
||||||
|
|
||||||
|
|
||||||
class PrinterClient:
|
class PrinterClient:
|
||||||
def __init__(self, client: BleakClient):
|
"""Low-level printer protocol client."""
|
||||||
self.client = client
|
|
||||||
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:
|
def __init__(self, transport):
|
||||||
self._buf.extend(data)
|
self._transport = transport
|
||||||
self._event.set()
|
self._seq = 0
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self):
|
||||||
await self.client.start_notify(NOTIFY_UUID, self._on_notify)
|
"""Initialize printer and verify it's ready."""
|
||||||
|
# Wake up the printer with a few null bytes (some printers need this)
|
||||||
async def send(self, data: bytes, wait: bool = False, timeout: float = 2.0) -> bytes:
|
await self._write(b"\x00" * 12)
|
||||||
async with self._lock:
|
# Enable printer (AiYin-specific command)
|
||||||
if wait:
|
await self._write(b"\x10\xFF\xFE\x01")
|
||||||
self._buf.clear()
|
# Get status to verify communication
|
||||||
self._event.clear()
|
status = await self.get_status()
|
||||||
await self.client.write_gatt_char(WRITE_UUID, data, response=False)
|
if not status.ok:
|
||||||
if wait:
|
raise PrinterError(f"Printer not ready: {status}")
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._event.wait(), timeout=timeout)
|
|
||||||
await asyncio.sleep(DELAY_NOTIFY_EXTRA)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise PrinterTimeout(f"No response within {timeout}s")
|
|
||||||
return bytes(self._buf)
|
|
||||||
|
|
||||||
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) ---
|
|
||||||
|
|
||||||
async def get_model(self) -> str:
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xF0]), wait=True)
|
|
||||||
return r.decode(errors="replace").strip() if r else "?"
|
|
||||||
|
|
||||||
async def get_firmware(self) -> str:
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xF1]), wait=True)
|
|
||||||
return r.decode(errors="replace").strip() if r else "?"
|
|
||||||
|
|
||||||
async def get_serial(self) -> str:
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xF2]), wait=True)
|
|
||||||
return r.decode(errors="replace").strip() if r else "?"
|
|
||||||
|
|
||||||
async def get_boot_version(self) -> str:
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xEF]), wait=True)
|
|
||||||
return r.decode(errors="replace").strip() if r else "?"
|
|
||||||
|
|
||||||
async def get_battery(self) -> int:
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x50, 0xF1]), wait=True)
|
|
||||||
if r and len(r) >= 2:
|
|
||||||
return r[-1]
|
|
||||||
return -1
|
|
||||||
|
|
||||||
async def get_status(self) -> PrinterStatus:
|
async def get_status(self) -> PrinterStatus:
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x40]), wait=True)
|
"""Query real-time printer status."""
|
||||||
if r:
|
await self._write(b"\x10\xFF\x40")
|
||||||
return PrinterStatus(r[-1])
|
resp = await self._read(1)
|
||||||
return PrinterStatus(0xFF)
|
return PrinterStatus(resp[0] if resp else 0)
|
||||||
|
|
||||||
async def get_density(self) -> bytes:
|
async def get_info(self) -> dict[str, str]:
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x11]), wait=True)
|
"""Query static printer information."""
|
||||||
return r
|
# Get model
|
||||||
|
await self._write(b"\x10\xFF\x20\xF0")
|
||||||
|
model = (await self._read(16)).rstrip(b"\x00").decode("ascii", errors="ignore")
|
||||||
|
# Get firmware
|
||||||
|
await self._write(b"\x10\xFF\x20\xF1")
|
||||||
|
firmware = (await self._read(16)).rstrip(b"\x00").decode("ascii", errors="ignore")
|
||||||
|
# Get serial
|
||||||
|
await self._write(b"\x10\xFF\x20\xF2")
|
||||||
|
serial = (await self._read(32)).rstrip(b"\x00").decode("ascii", errors="ignore")
|
||||||
|
# Get battery
|
||||||
|
await self._write(b"\x10\xFF\x50\xF1")
|
||||||
|
battery = (await self._read(2))[1] if (await self._read(2)) else 0
|
||||||
|
return {"model": model, "firmware": firmware, "serial": serial, "battery": battery}
|
||||||
|
|
||||||
async def get_shutdown_time(self) -> int:
|
async def get_all_info(self) -> dict[str, str]:
|
||||||
"""Returns auto-shutdown timeout in minutes."""
|
"""Query all printer information in one pipe-delimited response."""
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x13]), wait=True)
|
await self._write(b"\x10\xFF\x70")
|
||||||
if r and len(r) >= 2:
|
resp = await self._read(128)
|
||||||
return (r[0] << 8) | r[1]
|
if not resp or b"|" not in resp:
|
||||||
return -1
|
|
||||||
|
|
||||||
async def get_all_info(self) -> dict:
|
|
||||||
"""10 FF 70: returns pipe-delimited string with all device info."""
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x70]), wait=True)
|
|
||||||
if not r:
|
|
||||||
return {}
|
return {}
|
||||||
parts = r.decode(errors="replace").split("|")
|
parts = resp.decode("ascii", errors="ignore").strip().split("|")
|
||||||
if len(parts) >= 6:
|
if len(parts) >= 5:
|
||||||
return {
|
return {
|
||||||
"bt_name": parts[0],
|
"bt_name": parts[0],
|
||||||
"mac_classic": parts[1],
|
"bt_classic": parts[1],
|
||||||
"mac_ble": parts[2],
|
"bt_ble": parts[2],
|
||||||
"firmware": parts[3],
|
"firmware": parts[3],
|
||||||
"serial": parts[4],
|
"serial": parts[4],
|
||||||
"battery": f"{parts[5]}%",
|
"battery": parts[5] if len(parts) > 5 else "unknown",
|
||||||
}
|
}
|
||||||
return {"raw": r.decode(errors="replace")}
|
return {}
|
||||||
|
|
||||||
# --- Config commands (tested on D11s) ---
|
async def _write(self, data: bytes):
|
||||||
|
"""Write data to printer with chunking and pacing."""
|
||||||
|
chunk_size = CHUNK_SIZE_BLE if hasattr(self._transport, '_sock') else CHUNK_SIZE_CLASSIC
|
||||||
|
for i in range(0, len(data), chunk_size):
|
||||||
|
chunk = data[i : i + chunk_size]
|
||||||
|
self._transport.write(chunk)
|
||||||
|
await asyncio.sleep(DELAY_CHUNK_GAP)
|
||||||
|
|
||||||
async def set_density(self, level: int) -> bool:
|
async def _read(self, n: int, timeout: float = 2.0) -> bytes:
|
||||||
"""0=light, 1=medium, 2=thick. Returns True if printer responded OK."""
|
"""Read exactly n bytes from printer."""
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x10, 0x00, level]), wait=True)
|
buf = bytearray()
|
||||||
return r == b"OK"
|
start = asyncio.get_event_loop().time()
|
||||||
|
while len(buf) < n:
|
||||||
|
if asyncio.get_event_loop().time() - start > timeout:
|
||||||
|
raise PrinterError(f"Timeout reading {n} bytes (got {len(buf)})")
|
||||||
|
chunk = self._transport.read(min(256, n - len(buf)))
|
||||||
|
if not chunk:
|
||||||
|
await asyncio.sleep(DELAY_NOTIFY_EXTRA)
|
||||||
|
continue
|
||||||
|
buf.extend(chunk)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
async def set_paper_type(self, paper: int = PAPER_GAP) -> bool:
|
async def print_raster(self, raster: bytes, label_height: int):
|
||||||
"""0=gap/label, 1=black mark, 2=continuous."""
|
"""Print a raster image to the printer."""
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x84, paper]), wait=True)
|
# Enable printer
|
||||||
return r == b"OK"
|
await self._write(b"\x10\xFF\xFE\x01")
|
||||||
|
# Send raster header (GS v 0: 96px wide, mode 0, height=label_height)
|
||||||
|
header = bytearray([0x1D, 0x76, 0x30, 0x00, 0x0C, 0x00])
|
||||||
|
header.extend(label_height.to_bytes(2, 'little'))
|
||||||
|
await self._write(bytes(header))
|
||||||
|
# Send raster data
|
||||||
|
await self._write(raster)
|
||||||
|
# Form feed
|
||||||
|
await self._write(b"\x1D\x0C")
|
||||||
|
# Stop print and wait for completion
|
||||||
|
await self._write(b"\x10\xFF\xFE\x45")
|
||||||
|
# Wait for completion response (0xAA or "OK")
|
||||||
|
await asyncio.sleep(0.5) # Give printer time to process
|
||||||
|
|
||||||
async def set_shutdown_time(self, minutes: int) -> bool:
|
async def close(self):
|
||||||
hi = (minutes >> 8) & 0xFF
|
"""Close the connection cleanly."""
|
||||||
lo = minutes & 0xFF
|
try:
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x12, hi, lo]), wait=True)
|
# Stop any ongoing print job
|
||||||
return r == b"OK"
|
await self._write(b"\x10\xFF\xFE\x45")
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def factory_reset(self) -> bool:
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0x04]), wait=True)
|
|
||||||
return r == b"OK"
|
|
||||||
|
|
||||||
# --- Print control (AiYin-specific, from decompiled APK) ---
|
# --- High-level API ---
|
||||||
|
|
||||||
async def wakeup(self) -> None:
|
|
||||||
await self.send(b"\x00" * 12)
|
|
||||||
|
|
||||||
async def enable(self) -> None:
|
|
||||||
"""AiYin enable: 10 FF FE 01 (NOT 10 FF F1 03)."""
|
|
||||||
await self.send(bytes([0x10, 0xFF, 0xFE, 0x01]))
|
|
||||||
|
|
||||||
async def feed_dots(self, dots: int) -> None:
|
|
||||||
"""Feed paper forward by n dots."""
|
|
||||||
await self.send(bytes([0x1B, 0x4A, dots & 0xFF]))
|
|
||||||
|
|
||||||
async def form_feed(self) -> None:
|
|
||||||
"""Position to next label."""
|
|
||||||
await self.send(bytes([0x1D, 0x0C]))
|
|
||||||
|
|
||||||
async def stop_print(self) -> bool:
|
|
||||||
"""AiYin stop: 10 FF FE 45. Waits for 0xAA or 'OK'."""
|
|
||||||
r = await self.send(bytes([0x10, 0xFF, 0xFE, 0x45]), wait=True, timeout=60.0)
|
|
||||||
if r:
|
|
||||||
return r[0] == 0xAA or r.startswith(b"OK")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_info(self) -> dict:
|
|
||||||
status = await self.get_status()
|
|
||||||
return {
|
|
||||||
"model": await self.get_model(),
|
|
||||||
"firmware": await self.get_firmware(),
|
|
||||||
"boot": await self.get_boot_version(),
|
|
||||||
"serial": await self.get_serial(),
|
|
||||||
"battery": f"{await self.get_battery()}%",
|
|
||||||
"status": str(status),
|
|
||||||
"shutdown": f"{await self.get_shutdown_time()} min",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -412,165 +337,30 @@ async def connect(
|
|||||||
if classic:
|
if classic:
|
||||||
if not address:
|
if not address:
|
||||||
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
||||||
# D11s variants are commonly exposed on channel 1 or 3.
|
|
||||||
candidates = [channel, 1, 2, 3]
|
|
||||||
channels = [ch for i, ch in enumerate(candidates) if ch > 0 and ch not in candidates[:i]]
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for ch in channels:
|
|
||||||
try:
|
|
||||||
async with RFCOMMClient(address, ch) as client:
|
|
||||||
pc = PrinterClient(client)
|
|
||||||
await pc.start()
|
|
||||||
yield pc
|
|
||||||
return
|
|
||||||
except (PrinterError, PrinterTimeout) as exc:
|
|
||||||
# On Linux, a stale BlueZ device state can cause RFCOMM connect()
|
|
||||||
# to fail with [Errno 12] Out of memory. This is a known quirk.
|
|
||||||
# We treat this specific error as a signal to clear cache and retry.
|
|
||||||
if isinstance(exc.__cause__, OSError) and exc.__cause__.errno == errno.ENOMEM:
|
|
||||||
print(
|
|
||||||
f"Classic Bluetooth connection failed with [Errno 12] Out of memory on channel {ch}. "
|
|
||||||
"Clearing Bluetooth cache and retrying..."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Clear Bluetooth cache
|
|
||||||
remove_cmd = f'echo -e "remove {address}\nquit" | bluetoothctl'
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
remove_cmd,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(proc.communicate(), timeout=10.0)
|
|
||||||
print(f" Bluetooth cache cleared for {address}")
|
|
||||||
except Exception as remove_exc:
|
|
||||||
print(f" Failed to clear Bluetooth cache: {remove_exc}")
|
|
||||||
|
|
||||||
# Continue to next channel instead of breaking
|
# Try channels 1, 2, 3 - most common for Fichero printers
|
||||||
|
for ch in [channel, 1, 2, 3]:
|
||||||
|
if ch > 0:
|
||||||
|
try:
|
||||||
|
async with RFCOMMClient(address, ch) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
return
|
||||||
|
except (PrinterError, PrinterTimeout):
|
||||||
|
# Try next channel on error
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# For other errors, continue to next channel
|
# All channels failed
|
||||||
last_exc = exc
|
raise PrinterError(f"Classic Bluetooth connection failed for '{address}' after trying channels {channel}, 1, 2, 3")
|
||||||
|
|
||||||
# If the 'classic' flag is still true, it means the loop completed without
|
# BLE connection - keep it simple like original code
|
||||||
# hitting the ENOMEM fallback case, so all classic attempts failed.
|
try:
|
||||||
if classic:
|
|
||||||
if last_exc is not None:
|
|
||||||
raise PrinterError(
|
|
||||||
f"Classic Bluetooth connection failed for '{address}'. "
|
|
||||||
f"Tried channels: {channels}. Last error: {last_exc}"
|
|
||||||
) from last_exc
|
|
||||||
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
|
|
||||||
|
|
||||||
# If classic=False initially, or if it was set to False for the ENOMEM fallback:
|
|
||||||
if not classic:
|
|
||||||
target = await resolve_ble_target(address)
|
target = await resolve_ble_target(address)
|
||||||
def _is_retryable_ble_error(exc: Exception) -> bool:
|
async with BleakClient(target) as client:
|
||||||
msg = str(exc).lower()
|
pc = PrinterClient(client)
|
||||||
return any(
|
await pc.start()
|
||||||
token in msg
|
yield pc
|
||||||
for token in (
|
return
|
||||||
"timeout",
|
except Exception as exc:
|
||||||
"timed out",
|
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
||||||
"br-connection-timeout",
|
|
||||||
"failed to discover services",
|
|
||||||
"device disconnected",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
forced_rescan_done = False
|
|
||||||
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
|
|
||||||
try:
|
|
||||||
async with BleakClient(target) as client:
|
|
||||||
pc = PrinterClient(client)
|
|
||||||
await pc.start()
|
|
||||||
yield pc
|
|
||||||
return
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
last_exc = exc
|
|
||||||
if attempt < BLE_CONNECT_RETRIES:
|
|
||||||
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
|
||||||
continue
|
|
||||||
raise PrinterError(f"BLE connection timed out: {exc}") from exc
|
|
||||||
except BleakDBusError as exc:
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "br-connection-not-supported" in msg:
|
|
||||||
last_exc = exc
|
|
||||||
if not forced_rescan_done:
|
|
||||||
print(
|
|
||||||
"BLE connection failed with 'br-connection-not-supported'. "
|
|
||||||
"Attempting to clear device cache with 'bluetoothctl remove' and rescan."
|
|
||||||
)
|
|
||||||
# Aggressive recovery: try to remove the device from bluez's cache
|
|
||||||
if address:
|
|
||||||
try:
|
|
||||||
remove_cmd = f'echo -e "remove {address}\\nquit" | bluetoothctl'
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
remove_cmd,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(proc.communicate(), timeout=10.0)
|
|
||||||
except Exception as remove_exc:
|
|
||||||
print(f" Failed to run 'bluetoothctl remove': {remove_exc}")
|
|
||||||
|
|
||||||
forced_rescan_done = True
|
|
||||||
target = await resolve_ble_target(address)
|
|
||||||
if attempt < BLE_CONNECT_RETRIES:
|
|
||||||
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Enhanced recovery for persistent BLE issues
|
|
||||||
if attempt == BLE_CONNECT_RETRIES:
|
|
||||||
print(
|
|
||||||
"BLE connection persistently failing. "
|
|
||||||
"Attempting comprehensive Bluetooth stack reset..."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Comprehensive Bluetooth reset
|
|
||||||
reset_commands = [
|
|
||||||
f'echo -e "remove {address}\nquit" | bluetoothctl',
|
|
||||||
"sudo systemctl restart bluetooth",
|
|
||||||
"sleep 3",
|
|
||||||
f'echo -e "scan on\nscan off\nquit" | bluetoothctl'
|
|
||||||
]
|
|
||||||
for cmd in reset_commands:
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
cmd,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
|
||||||
|
|
||||||
print("Bluetooth stack reset completed. Retrying connection...")
|
|
||||||
target = await resolve_ble_target(address)
|
|
||||||
# One final attempt after reset
|
|
||||||
async with BleakClient(target) as client:
|
|
||||||
pc = PrinterClient(client)
|
|
||||||
await pc.start()
|
|
||||||
yield pc
|
|
||||||
return
|
|
||||||
except Exception as reset_exc:
|
|
||||||
print(f"Comprehensive reset failed: {reset_exc}")
|
|
||||||
|
|
||||||
raise PrinterError(
|
|
||||||
"BLE connection failed (br-connection-not-supported) after comprehensive recovery. "
|
|
||||||
"This typically indicates missing host_dbus permissions in Home Assistant. "
|
|
||||||
"Please ensure your add-on configuration includes: host_dbus: true"
|
|
||||||
) from exc
|
|
||||||
last_exc = exc
|
|
||||||
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
|
||||||
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
|
||||||
continue
|
|
||||||
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
|
||||||
except BleakError as exc:
|
|
||||||
last_exc = exc
|
|
||||||
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
|
||||||
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
|
||||||
continue
|
|
||||||
raise PrinterError(f"BLE error: {exc}") from exc
|
|
||||||
if last_exc is not None:
|
|
||||||
raise PrinterError(
|
|
||||||
f"BLE connection failed after {BLE_CONNECT_RETRIES} attempts: {last_exc}"
|
|
||||||
) from last_exc
|
|
||||||
raise PrinterError("BLE connection failed for unknown reason.")
|
|
||||||
576
fichero_printer/fichero/printer.py.backup
Normal file
576
fichero_printer/fichero/printer.py.backup
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
"""
|
||||||
|
Fichero / D11s thermal label printer - BLE + Classic Bluetooth interface.
|
||||||
|
|
||||||
|
Protocol reverse-engineered from decompiled Fichero APK (com.lj.fichero).
|
||||||
|
Device class: AiYinNormalDevice (LuckPrinter SDK)
|
||||||
|
96px wide printhead (12 bytes/row), 203 DPI, prints 1-bit raster images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import errno
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
||||||
|
from bleak.exc import BleakDBusError, BleakError
|
||||||
|
|
||||||
|
# --- 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_")
|
||||||
|
|
||||||
|
# Using the 18f0 service (any of the four BLE UART services work)
|
||||||
|
WRITE_UUID = "00002af1-0000-1000-8000-00805f9b34fb"
|
||||||
|
NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
|
# --- Printhead ---
|
||||||
|
|
||||||
|
PRINTHEAD_PX = 96
|
||||||
|
BYTES_PER_ROW = PRINTHEAD_PX // 8 # 12
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
PAPER_GAP = 0x00
|
||||||
|
PAPER_BLACK_MARK = 0x01
|
||||||
|
PAPER_CONTINUOUS = 0x02
|
||||||
|
|
||||||
|
# --- Timing (seconds) - empirically tuned against D11s fw 2.4.6 ---
|
||||||
|
|
||||||
|
DELAY_AFTER_DENSITY = 0.10 # printer needs time to apply density setting
|
||||||
|
DELAY_COMMAND_GAP = 0.05 # minimum gap between sequential commands
|
||||||
|
DELAY_CHUNK_GAP = 0.02 # inter-chunk pacing for BLE throughput
|
||||||
|
DELAY_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
|
||||||
|
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
|
||||||
|
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
||||||
|
BLE_CONNECT_RETRIES = 3 # retry transient BLE connect failures
|
||||||
|
BLE_CONNECT_BACKOFF = 0.7 # base backoff in seconds (linear: n * base)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Exceptions ---
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterError(Exception):
|
||||||
|
"""Base exception for printer operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterNotFound(PrinterError):
|
||||||
|
"""No Fichero/D11s printer found during BLE scan."""
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterTimeout(PrinterError):
|
||||||
|
"""Printer did not respond within the expected time."""
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterNotReady(PrinterError):
|
||||||
|
"""Printer status indicates it cannot print."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Discovery ---
|
||||||
|
|
||||||
|
|
||||||
|
async def find_printer() -> str:
|
||||||
|
"""Scan BLE for a Fichero/D11s printer. Returns the address."""
|
||||||
|
print("Scanning for printer...")
|
||||||
|
devices = await BleakScanner.discover(timeout=8)
|
||||||
|
for d in devices:
|
||||||
|
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
|
||||||
|
print(f" Found {d.name} at {d.address}")
|
||||||
|
return d.address
|
||||||
|
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_ble_target(address: str | None = None):
|
||||||
|
"""Resolve a BLE target as Bleak device object when possible.
|
||||||
|
|
||||||
|
Passing a discovered device object to BleakClient helps BlueZ keep the
|
||||||
|
correct LE context for dual-mode environments.
|
||||||
|
"""
|
||||||
|
if address:
|
||||||
|
device = await BleakScanner.find_device_by_address(address, timeout=8.0)
|
||||||
|
if device is not None:
|
||||||
|
return device
|
||||||
|
# Fallback to active scan/match before giving up.
|
||||||
|
devices = await BleakScanner.discover(timeout=8)
|
||||||
|
for d in devices:
|
||||||
|
if d.address and d.address.lower() == address.lower():
|
||||||
|
return d
|
||||||
|
print(f" Warning: BLE device {address} not found in scan. Falling back to direct address connection.")
|
||||||
|
return address
|
||||||
|
devices = await BleakScanner.discover(timeout=8)
|
||||||
|
for d in devices:
|
||||||
|
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
|
||||||
|
print(f" Found {d.name} at {d.address}")
|
||||||
|
return d
|
||||||
|
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Status ---
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterStatus:
|
||||||
|
"""Parsed status byte from 10 FF 40."""
|
||||||
|
|
||||||
|
def __init__(self, byte: int):
|
||||||
|
self.raw = byte
|
||||||
|
self.printing = bool(byte & 0x01)
|
||||||
|
self.cover_open = bool(byte & 0x02)
|
||||||
|
self.no_paper = bool(byte & 0x04)
|
||||||
|
self.low_battery = bool(byte & 0x08)
|
||||||
|
self.overheated = bool(byte & 0x10 or byte & 0x40)
|
||||||
|
self.charging = bool(byte & 0x20)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
flags = []
|
||||||
|
if self.printing:
|
||||||
|
flags.append("printing")
|
||||||
|
if self.cover_open:
|
||||||
|
flags.append("cover open")
|
||||||
|
if self.no_paper:
|
||||||
|
flags.append("no paper")
|
||||||
|
if self.low_battery:
|
||||||
|
flags.append("low battery")
|
||||||
|
if self.overheated:
|
||||||
|
flags.append("overheated")
|
||||||
|
if self.charging:
|
||||||
|
flags.append("charging")
|
||||||
|
return ", ".join(flags) if flags else "ready"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
# uvloop's sock_connect path goes through getaddrinfo and doesn't
|
||||||
|
# support AF_BLUETOOTH addresses reliably. Use direct socket connect
|
||||||
|
# in a thread instead.
|
||||||
|
sock.settimeout(10.0)
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
sock.connect,
|
||||||
|
(self._address, self._channel),
|
||||||
|
)
|
||||||
|
sock.setblocking(False)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
sock.close()
|
||||||
|
raise PrinterTimeout(
|
||||||
|
f"Classic Bluetooth connection timed out to {self._address} (channel {self._channel})."
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
sock.close()
|
||||||
|
raise PrinterError(
|
||||||
|
f"Classic Bluetooth connection failed for '{self._address}' (channel {self._channel}): {exc}"
|
||||||
|
) from exc
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterClient:
|
||||||
|
def __init__(self, client: BleakClient):
|
||||||
|
self.client = client
|
||||||
|
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)
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
await self.client.start_notify(NOTIFY_UUID, self._on_notify)
|
||||||
|
|
||||||
|
async def send(self, data: bytes, wait: bool = False, timeout: float = 2.0) -> bytes:
|
||||||
|
async with self._lock:
|
||||||
|
if wait:
|
||||||
|
self._buf.clear()
|
||||||
|
self._event.clear()
|
||||||
|
await self.client.write_gatt_char(WRITE_UUID, data, response=False)
|
||||||
|
if wait:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._event.wait(), timeout=timeout)
|
||||||
|
await asyncio.sleep(DELAY_NOTIFY_EXTRA)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise PrinterTimeout(f"No response within {timeout}s")
|
||||||
|
return bytes(self._buf)
|
||||||
|
|
||||||
|
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) ---
|
||||||
|
|
||||||
|
async def get_model(self) -> str:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xF0]), wait=True)
|
||||||
|
return r.decode(errors="replace").strip() if r else "?"
|
||||||
|
|
||||||
|
async def get_firmware(self) -> str:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xF1]), wait=True)
|
||||||
|
return r.decode(errors="replace").strip() if r else "?"
|
||||||
|
|
||||||
|
async def get_serial(self) -> str:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xF2]), wait=True)
|
||||||
|
return r.decode(errors="replace").strip() if r else "?"
|
||||||
|
|
||||||
|
async def get_boot_version(self) -> str:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x20, 0xEF]), wait=True)
|
||||||
|
return r.decode(errors="replace").strip() if r else "?"
|
||||||
|
|
||||||
|
async def get_battery(self) -> int:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x50, 0xF1]), wait=True)
|
||||||
|
if r and len(r) >= 2:
|
||||||
|
return r[-1]
|
||||||
|
return -1
|
||||||
|
|
||||||
|
async def get_status(self) -> PrinterStatus:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x40]), wait=True)
|
||||||
|
if r:
|
||||||
|
return PrinterStatus(r[-1])
|
||||||
|
return PrinterStatus(0xFF)
|
||||||
|
|
||||||
|
async def get_density(self) -> bytes:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x11]), wait=True)
|
||||||
|
return r
|
||||||
|
|
||||||
|
async def get_shutdown_time(self) -> int:
|
||||||
|
"""Returns auto-shutdown timeout in minutes."""
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x13]), wait=True)
|
||||||
|
if r and len(r) >= 2:
|
||||||
|
return (r[0] << 8) | r[1]
|
||||||
|
return -1
|
||||||
|
|
||||||
|
async def get_all_info(self) -> dict:
|
||||||
|
"""10 FF 70: returns pipe-delimited string with all device info."""
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x70]), wait=True)
|
||||||
|
if not r:
|
||||||
|
return {}
|
||||||
|
parts = r.decode(errors="replace").split("|")
|
||||||
|
if len(parts) >= 6:
|
||||||
|
return {
|
||||||
|
"bt_name": parts[0],
|
||||||
|
"mac_classic": parts[1],
|
||||||
|
"mac_ble": parts[2],
|
||||||
|
"firmware": parts[3],
|
||||||
|
"serial": parts[4],
|
||||||
|
"battery": f"{parts[5]}%",
|
||||||
|
}
|
||||||
|
return {"raw": r.decode(errors="replace")}
|
||||||
|
|
||||||
|
# --- Config commands (tested on D11s) ---
|
||||||
|
|
||||||
|
async def set_density(self, level: int) -> bool:
|
||||||
|
"""0=light, 1=medium, 2=thick. Returns True if printer responded OK."""
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x10, 0x00, level]), wait=True)
|
||||||
|
return r == b"OK"
|
||||||
|
|
||||||
|
async def set_paper_type(self, paper: int = PAPER_GAP) -> bool:
|
||||||
|
"""0=gap/label, 1=black mark, 2=continuous."""
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x84, paper]), wait=True)
|
||||||
|
return r == b"OK"
|
||||||
|
|
||||||
|
async def set_shutdown_time(self, minutes: int) -> bool:
|
||||||
|
hi = (minutes >> 8) & 0xFF
|
||||||
|
lo = minutes & 0xFF
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x12, hi, lo]), wait=True)
|
||||||
|
return r == b"OK"
|
||||||
|
|
||||||
|
async def factory_reset(self) -> bool:
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0x04]), wait=True)
|
||||||
|
return r == b"OK"
|
||||||
|
|
||||||
|
# --- Print control (AiYin-specific, from decompiled APK) ---
|
||||||
|
|
||||||
|
async def wakeup(self) -> None:
|
||||||
|
await self.send(b"\x00" * 12)
|
||||||
|
|
||||||
|
async def enable(self) -> None:
|
||||||
|
"""AiYin enable: 10 FF FE 01 (NOT 10 FF F1 03)."""
|
||||||
|
await self.send(bytes([0x10, 0xFF, 0xFE, 0x01]))
|
||||||
|
|
||||||
|
async def feed_dots(self, dots: int) -> None:
|
||||||
|
"""Feed paper forward by n dots."""
|
||||||
|
await self.send(bytes([0x1B, 0x4A, dots & 0xFF]))
|
||||||
|
|
||||||
|
async def form_feed(self) -> None:
|
||||||
|
"""Position to next label."""
|
||||||
|
await self.send(bytes([0x1D, 0x0C]))
|
||||||
|
|
||||||
|
async def stop_print(self) -> bool:
|
||||||
|
"""AiYin stop: 10 FF FE 45. Waits for 0xAA or 'OK'."""
|
||||||
|
r = await self.send(bytes([0x10, 0xFF, 0xFE, 0x45]), wait=True, timeout=60.0)
|
||||||
|
if r:
|
||||||
|
return r[0] == 0xAA or r.startswith(b"OK")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_info(self) -> dict:
|
||||||
|
status = await self.get_status()
|
||||||
|
return {
|
||||||
|
"model": await self.get_model(),
|
||||||
|
"firmware": await self.get_firmware(),
|
||||||
|
"boot": await self.get_boot_version(),
|
||||||
|
"serial": await self.get_serial(),
|
||||||
|
"battery": f"{await self.get_battery()}%",
|
||||||
|
"status": str(status),
|
||||||
|
"shutdown": f"{await self.get_shutdown_time()} min",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
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."""
|
||||||
|
if classic:
|
||||||
|
if not address:
|
||||||
|
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
||||||
|
# D11s variants are commonly exposed on channel 1 or 3.
|
||||||
|
candidates = [channel, 1, 2, 3]
|
||||||
|
channels = [ch for i, ch in enumerate(candidates) if ch > 0 and ch not in candidates[:i]]
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for ch in channels:
|
||||||
|
try:
|
||||||
|
async with RFCOMMClient(address, ch) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
return
|
||||||
|
except (PrinterError, PrinterTimeout) as exc:
|
||||||
|
# On Linux, a stale BlueZ device state can cause RFCOMM connect()
|
||||||
|
# to fail with [Errno 12] Out of memory. This is a known quirk.
|
||||||
|
# We treat this specific error as a signal to clear cache and retry.
|
||||||
|
if isinstance(exc.__cause__, OSError) and exc.__cause__.errno == errno.ENOMEM:
|
||||||
|
print(
|
||||||
|
f"Classic Bluetooth connection failed with [Errno 12] Out of memory on channel {ch}. "
|
||||||
|
"Clearing Bluetooth cache and retrying..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Clear Bluetooth cache
|
||||||
|
remove_cmd = f'echo -e "remove {address}\nquit" | bluetoothctl'
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
remove_cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(proc.communicate(), timeout=10.0)
|
||||||
|
print(f" Bluetooth cache cleared for {address}")
|
||||||
|
except Exception as remove_exc:
|
||||||
|
print(f" Failed to clear Bluetooth cache: {remove_exc}")
|
||||||
|
|
||||||
|
# Continue to next channel instead of breaking
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For other errors, continue to next channel
|
||||||
|
last_exc = exc
|
||||||
|
|
||||||
|
# If the 'classic' flag is still true, it means the loop completed without
|
||||||
|
# hitting the ENOMEM fallback case, so all classic attempts failed.
|
||||||
|
if classic:
|
||||||
|
if last_exc is not None:
|
||||||
|
raise PrinterError(
|
||||||
|
f"Classic Bluetooth connection failed for '{address}'. "
|
||||||
|
f"Tried channels: {channels}. Last error: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
|
||||||
|
|
||||||
|
# If classic=False initially, or if it was set to False for the ENOMEM fallback:
|
||||||
|
if not classic:
|
||||||
|
target = await resolve_ble_target(address)
|
||||||
|
def _is_retryable_ble_error(exc: Exception) -> bool:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
return any(
|
||||||
|
token in msg
|
||||||
|
for token in (
|
||||||
|
"timeout",
|
||||||
|
"timed out",
|
||||||
|
"br-connection-timeout",
|
||||||
|
"failed to discover services",
|
||||||
|
"device disconnected",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
forced_rescan_done = False
|
||||||
|
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
async with BleakClient(target) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
return
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
|
raise PrinterError(f"BLE connection timed out: {exc}") from exc
|
||||||
|
except BleakDBusError as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "br-connection-not-supported" in msg:
|
||||||
|
last_exc = exc
|
||||||
|
if not forced_rescan_done:
|
||||||
|
print(
|
||||||
|
"BLE connection failed with 'br-connection-not-supported'. "
|
||||||
|
"Attempting to clear device cache with 'bluetoothctl remove' and rescan."
|
||||||
|
)
|
||||||
|
# Aggressive recovery: try to remove the device from bluez's cache
|
||||||
|
if address:
|
||||||
|
try:
|
||||||
|
remove_cmd = f'echo -e "remove {address}\\nquit" | bluetoothctl'
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
remove_cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(proc.communicate(), timeout=10.0)
|
||||||
|
except Exception as remove_exc:
|
||||||
|
print(f" Failed to run 'bluetoothctl remove': {remove_exc}")
|
||||||
|
|
||||||
|
forced_rescan_done = True
|
||||||
|
target = await resolve_ble_target(address)
|
||||||
|
if attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Enhanced recovery for persistent BLE issues
|
||||||
|
if attempt == BLE_CONNECT_RETRIES:
|
||||||
|
print(
|
||||||
|
"BLE connection persistently failing. "
|
||||||
|
"Attempting comprehensive Bluetooth stack reset..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Comprehensive Bluetooth reset
|
||||||
|
reset_commands = [
|
||||||
|
f'echo -e "remove {address}\nquit" | bluetoothctl',
|
||||||
|
"sudo systemctl restart bluetooth",
|
||||||
|
"sleep 3",
|
||||||
|
f'echo -e "scan on\nscan off\nquit" | bluetoothctl'
|
||||||
|
]
|
||||||
|
for cmd in reset_commands:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
||||||
|
|
||||||
|
print("Bluetooth stack reset completed. Retrying connection...")
|
||||||
|
target = await resolve_ble_target(address)
|
||||||
|
# One final attempt after reset
|
||||||
|
async with BleakClient(target) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
return
|
||||||
|
except Exception as reset_exc:
|
||||||
|
print(f"Comprehensive reset failed: {reset_exc}")
|
||||||
|
|
||||||
|
raise PrinterError(
|
||||||
|
"BLE connection failed (br-connection-not-supported) after comprehensive recovery. "
|
||||||
|
"This typically indicates missing host_dbus permissions in Home Assistant. "
|
||||||
|
"Please ensure your add-on configuration includes: host_dbus: true"
|
||||||
|
) from exc
|
||||||
|
last_exc = exc
|
||||||
|
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
|
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
||||||
|
except BleakError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
|
raise PrinterError(f"BLE error: {exc}") from exc
|
||||||
|
if last_exc is not None:
|
||||||
|
raise PrinterError(
|
||||||
|
f"BLE connection failed after {BLE_CONNECT_RETRIES} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
raise PrinterError("BLE connection failed for unknown reason.")
|
||||||
Reference in New Issue
Block a user