Restructure into fichero/ package and fix review issues
Split monolithic printer.py into fichero/{printer,imaging,cli}.py.
Add asyncio.Lock to prevent notification buffer races, replace sys.exit
with PrinterNotFound/PrinterTimeout/PrinterNotReady exceptions, name all
magic sleep values as DELAY_* constants, add image_to_raster validation,
add --font-size and --label-height to text subcommand, add input
validation to set command, add type annotations to public API, fix
misleading paper type comment, remove duplicate PROTOCOL.md section,
update README for new CLI entry point and library usage.
This commit is contained in:
49
README.md
49
README.md
@@ -66,7 +66,7 @@ Requires Web Bluetooth, so Chrome/Edge/Opera only. Firefox and Safari don't supp
|
|||||||
Requires Python 3.10+ and uv. Turn on the printer and run:
|
Requires Python 3.10+ and uv. Turn on the printer and run:
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run printer.py info
|
uv run fichero info
|
||||||
```
|
```
|
||||||
|
|
||||||
This auto-discovers the printer via BLE scan. To skip scanning on subsequent runs, find your printer's address from the scan output and save it:
|
This auto-discovers the printer via BLE scan. To skip scanning on subsequent runs, find your printer's address from the scan output and save it:
|
||||||
@@ -78,45 +78,68 @@ export FICHERO_ADDR=AA:BB:CC:DD:EE:FF
|
|||||||
You can also pass it per-command:
|
You can also pass it per-command:
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run printer.py --address AA:BB:CC:DD:EE:FF info
|
uv run fichero --address AA:BB:CC:DD:EE:FF info
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run printer.py --help
|
uv run fichero --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Printing
|
### Printing
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run printer.py text "Hello World"
|
uv run fichero text "Hello World"
|
||||||
uv run printer.py text "Fragile" --density 2 --copies 3
|
uv run fichero text "Fragile" --density 2 --copies 3
|
||||||
uv run printer.py image label.png
|
uv run fichero text "Big Label" --font-size 40 --label-height 180
|
||||||
uv run printer.py image label.png --density 1 --copies 2
|
uv run fichero image label.png
|
||||||
|
uv run fichero image label.png --density 1 --copies 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Density: 0=light, 1=medium (default), 2=thick.
|
Density: 0=light, 1=medium (default), 2=thick.
|
||||||
|
|
||||||
|
Text labels accept `--font-size` (default 24) and `--label-height` in pixels (default 240).
|
||||||
|
|
||||||
### Device info
|
### Device info
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run printer.py info
|
uv run fichero info
|
||||||
uv run printer.py status
|
uv run fichero status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run printer.py set density 2
|
uv run fichero set density 2
|
||||||
uv run printer.py set shutdown 30
|
uv run fichero set shutdown 30
|
||||||
uv run printer.py set paper gap
|
uv run fichero set paper gap
|
||||||
```
|
```
|
||||||
|
|
||||||
- `density` - how dark the print is. 0 is faint, 1 is normal, 2 is the darkest. Higher density uses more battery and can smudge on some label stock.
|
- `density` - how dark the print is. 0 is faint, 1 is normal, 2 is the darkest. Higher density uses more battery and can smudge on some label stock.
|
||||||
- `shutdown` - how many minutes the printer waits before turning itself off when idle. Set it higher if you're tired of turning it back on between prints.
|
- `shutdown` - how many minutes the printer waits before turning itself off when idle (1-480). Set it higher if you're tired of turning it back on between prints.
|
||||||
- `paper` - what kind of label stock you're using. `gap` is the default, for labels with spacing between them (the printer detects the gap to know where to stop). `black` is for rolls with a black mark between labels. `continuous` is for receipt-style rolls with no markings.
|
- `paper` - what kind of label stock you're using. `gap` is the default, for labels with spacing between them (the printer detects the gap to know where to stop). `black` is for rolls with a black mark between labels. `continuous` is for receipt-style rolls with no markings.
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from fichero import connect, PrinterNotFound
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with connect() as pc:
|
||||||
|
info = await pc.get_info()
|
||||||
|
print(info)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
The package exports `PrinterClient`, `connect`, `PrinterError`, `PrinterNotFound`, `PrinterTimeout`, `PrinterNotReady`, and `PrinterStatus`.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Emoji support in text labels. The default Pillow font has no emoji glyphs, so they render as squares. Needs two-pass rendering: split text into emoji/non-emoji segments, render emoji with Apple Color Emoji (macOS) or Noto Color Emoji (Linux) using `embedded_color=True`, then composite onto the label.
|
||||||
|
|
||||||
## Protocol and reverse engineering
|
## Protocol and reverse engineering
|
||||||
|
|
||||||
See [docs/PROTOCOL.md](docs/PROTOCOL.md) for the full command reference, print sequence, and how this was reverse-engineered.
|
See [docs/PROTOCOL.md](docs/PROTOCOL.md) for the full command reference, print sequence, and how this was reverse-engineered.
|
||||||
|
|||||||
@@ -181,22 +181,6 @@ Fichero-branded printers:
|
|||||||
- Fichero 4575 -> DP_D1H (Lujiang)
|
- Fichero 4575 -> DP_D1H (Lujiang)
|
||||||
- Fichero 4437 -> DP_L81H (Lujiang)
|
- Fichero 4437 -> DP_L81H (Lujiang)
|
||||||
|
|
||||||
## Print sequence
|
|
||||||
|
|
||||||
The exact command sequence used by the official Fichero app, extracted from the decompiled APK and verified against hardware:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 10 FF 10 00 nn Set density (0-2)
|
|
||||||
2. 10 FF 84 00 Set paper type (gap label)
|
|
||||||
3. 00 x12 Wake up (12 null bytes)
|
|
||||||
4. 10 FF FE 01 Enable printer (AiYin)
|
|
||||||
5. 1D 76 30 00 0C 00 yL yH Raster image (ESC/POS GS v 0)
|
|
||||||
[pixel data...] 96px wide, 1-bit, MSB first
|
|
||||||
6. 1D 0C Feed to next label
|
|
||||||
7. 10 FF FE 45 Stop print job (AiYin)
|
|
||||||
wait for 0xAA or "OK"
|
|
||||||
```
|
|
||||||
|
|
||||||
## How this was reverse-engineered
|
## How this was reverse-engineered
|
||||||
|
|
||||||
1. BLE enumeration with bleak to find services and characteristics
|
1. BLE enumeration with bleak to find services and characteristics
|
||||||
|
|||||||
21
fichero/__init__.py
Normal file
21
fichero/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Fichero D11s thermal label printer - BLE interface."""
|
||||||
|
|
||||||
|
from fichero.printer import (
|
||||||
|
PrinterClient,
|
||||||
|
PrinterError,
|
||||||
|
PrinterNotFound,
|
||||||
|
PrinterNotReady,
|
||||||
|
PrinterStatus,
|
||||||
|
PrinterTimeout,
|
||||||
|
connect,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PrinterClient",
|
||||||
|
"PrinterError",
|
||||||
|
"PrinterNotFound",
|
||||||
|
"PrinterNotReady",
|
||||||
|
"PrinterStatus",
|
||||||
|
"PrinterTimeout",
|
||||||
|
"connect",
|
||||||
|
]
|
||||||
190
fichero/cli.py
Normal file
190
fichero/cli.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""CLI for Fichero D11s thermal label printer."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from fichero.imaging import image_to_raster, prepare_image, text_to_image
|
||||||
|
from fichero.printer import (
|
||||||
|
BYTES_PER_ROW,
|
||||||
|
DELAY_AFTER_DENSITY,
|
||||||
|
DELAY_AFTER_FEED,
|
||||||
|
DELAY_COMMAND_GAP,
|
||||||
|
DELAY_RASTER_SETTLE,
|
||||||
|
PAPER_GAP,
|
||||||
|
PrinterClient,
|
||||||
|
PrinterError,
|
||||||
|
PrinterNotReady,
|
||||||
|
connect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def do_print(
|
||||||
|
pc: PrinterClient,
|
||||||
|
img: Image.Image,
|
||||||
|
density: int = 1,
|
||||||
|
paper: int = PAPER_GAP,
|
||||||
|
copies: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
img = prepare_image(img)
|
||||||
|
rows = img.height
|
||||||
|
raster = image_to_raster(img)
|
||||||
|
|
||||||
|
print(f" Image: {img.width}x{rows}, {len(raster)} bytes, {copies} copies")
|
||||||
|
|
||||||
|
status = await pc.get_status()
|
||||||
|
if not status.ok:
|
||||||
|
raise PrinterNotReady(f"Printer not ready: {status}")
|
||||||
|
|
||||||
|
await pc.set_density(density)
|
||||||
|
await asyncio.sleep(DELAY_AFTER_DENSITY)
|
||||||
|
|
||||||
|
for copy_num in range(copies):
|
||||||
|
if copies > 1:
|
||||||
|
print(f" Copy {copy_num + 1}/{copies}...")
|
||||||
|
|
||||||
|
# AiYin print sequence (from decompiled APK)
|
||||||
|
await pc.set_paper_type(paper)
|
||||||
|
await asyncio.sleep(DELAY_COMMAND_GAP)
|
||||||
|
await pc.wakeup()
|
||||||
|
await asyncio.sleep(DELAY_COMMAND_GAP)
|
||||||
|
await pc.enable()
|
||||||
|
await asyncio.sleep(DELAY_COMMAND_GAP)
|
||||||
|
|
||||||
|
# Raster image: GS v 0 m xL xH yL yH <data>
|
||||||
|
yl = rows & 0xFF
|
||||||
|
yh = (rows >> 8) & 0xFF
|
||||||
|
header = bytes([0x1D, 0x76, 0x30, 0x00, BYTES_PER_ROW, 0x00, yl, yh])
|
||||||
|
await pc.send_chunked(header + raster)
|
||||||
|
|
||||||
|
await asyncio.sleep(DELAY_RASTER_SETTLE)
|
||||||
|
await pc.form_feed()
|
||||||
|
await asyncio.sleep(DELAY_AFTER_FEED)
|
||||||
|
|
||||||
|
ok = await pc.stop_print()
|
||||||
|
if not ok:
|
||||||
|
print(" WARNING: no OK/0xAA from stop command")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_info(args: argparse.Namespace) -> None:
|
||||||
|
async with connect(args.address) as pc:
|
||||||
|
info = await pc.get_info()
|
||||||
|
for k, v in info.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
all_info = await pc.get_all_info()
|
||||||
|
for k, v in all_info.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_status(args: argparse.Namespace) -> None:
|
||||||
|
async with connect(args.address) as pc:
|
||||||
|
status = await pc.get_status()
|
||||||
|
print(f" Status: {status}")
|
||||||
|
print(f" Raw: 0x{status.raw:02X} ({status.raw:08b})")
|
||||||
|
print(f" printing={status.printing} cover_open={status.cover_open} "
|
||||||
|
f"no_paper={status.no_paper} low_battery={status.low_battery} "
|
||||||
|
f"overheated={status.overheated} charging={status.charging}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_text(args: argparse.Namespace) -> None:
|
||||||
|
text = " ".join(args.text)
|
||||||
|
img = text_to_image(text, font_size=args.font_size, label_height=args.label_height)
|
||||||
|
async with connect(args.address) as pc:
|
||||||
|
print(f'Printing "{text}"...')
|
||||||
|
ok = await do_print(pc, img, args.density, copies=args.copies)
|
||||||
|
print("Done." if ok else "FAILED.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_image(args: argparse.Namespace) -> None:
|
||||||
|
img = Image.open(args.path)
|
||||||
|
async with connect(args.address) as pc:
|
||||||
|
print(f"Printing {args.path}...")
|
||||||
|
ok = await do_print(pc, img, args.density, copies=args.copies)
|
||||||
|
print("Done." if ok else "FAILED.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_set(args: argparse.Namespace) -> None:
|
||||||
|
async with connect(args.address) as pc:
|
||||||
|
if args.setting == "density":
|
||||||
|
val = int(args.value)
|
||||||
|
if not 0 <= val <= 2:
|
||||||
|
print(" ERROR: density must be 0, 1, or 2")
|
||||||
|
return
|
||||||
|
ok = await pc.set_density(val)
|
||||||
|
print(f" Set density={args.value}: {'OK' if ok else 'FAILED'}")
|
||||||
|
elif args.setting == "shutdown":
|
||||||
|
val = int(args.value)
|
||||||
|
if not 1 <= val <= 480:
|
||||||
|
print(" ERROR: shutdown must be 1-480 minutes")
|
||||||
|
return
|
||||||
|
ok = await pc.set_shutdown_time(val)
|
||||||
|
print(f" Set shutdown={args.value}min: {'OK' if ok else 'FAILED'}")
|
||||||
|
elif args.setting == "paper":
|
||||||
|
types = {"gap": 0, "black": 1, "continuous": 2}
|
||||||
|
if args.value in types:
|
||||||
|
val = types[args.value]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
val = int(args.value)
|
||||||
|
except ValueError:
|
||||||
|
print(" ERROR: paper must be gap, black, continuous, or 0-2")
|
||||||
|
return
|
||||||
|
if not 0 <= val <= 2:
|
||||||
|
print(" ERROR: paper must be gap, black, continuous, or 0-2")
|
||||||
|
return
|
||||||
|
ok = await pc.set_paper_type(val)
|
||||||
|
print(f" Set paper={args.value}: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Fichero D11s Label Printer")
|
||||||
|
parser.add_argument("--address", default=os.environ.get("FICHERO_ADDR"),
|
||||||
|
help="BLE address (skip scanning, or set FICHERO_ADDR)")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_info = sub.add_parser("info", help="Show device info")
|
||||||
|
p_info.set_defaults(func=cmd_info)
|
||||||
|
|
||||||
|
p_status = sub.add_parser("status", help="Show detailed status")
|
||||||
|
p_status.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
p_text = sub.add_parser("text", help="Print text label")
|
||||||
|
p_text.add_argument("text", nargs="+", help="Text to print")
|
||||||
|
p_text.add_argument("--density", type=int, default=1, choices=[0, 1, 2],
|
||||||
|
help="Print density: 0=light, 1=medium, 2=thick")
|
||||||
|
p_text.add_argument("--copies", type=int, default=1, help="Number of copies")
|
||||||
|
p_text.add_argument("--font-size", type=int, default=24, help="Font size in points")
|
||||||
|
p_text.add_argument("--label-height", type=int, default=240,
|
||||||
|
help="Label height in pixels (default: 240)")
|
||||||
|
p_text.set_defaults(func=cmd_text)
|
||||||
|
|
||||||
|
p_image = sub.add_parser("image", help="Print image file")
|
||||||
|
p_image.add_argument("path", help="Path to image file")
|
||||||
|
p_image.add_argument("--density", type=int, default=1, choices=[0, 1, 2],
|
||||||
|
help="Print density: 0=light, 1=medium, 2=thick")
|
||||||
|
p_image.add_argument("--copies", type=int, default=1, help="Number of copies")
|
||||||
|
p_image.set_defaults(func=cmd_image)
|
||||||
|
|
||||||
|
p_set = sub.add_parser("set", help="Change printer settings")
|
||||||
|
p_set.add_argument("setting", choices=["density", "shutdown", "paper"],
|
||||||
|
help="Setting to change")
|
||||||
|
p_set.add_argument("value", help="New value")
|
||||||
|
p_set.set_defaults(func=cmd_set)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
asyncio.run(args.func(args))
|
||||||
|
except PrinterError as e:
|
||||||
|
print(f" ERROR: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
50
fichero/imaging.py
Normal file
50
fichero/imaging.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Image processing for Fichero D11s thermal label printer."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from fichero.printer import PRINTHEAD_PX
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_image(img: Image.Image, max_rows: int = 240) -> Image.Image:
|
||||||
|
"""Convert any image to 96px wide, 1-bit, black on white."""
|
||||||
|
img = img.convert("L")
|
||||||
|
w, h = img.size
|
||||||
|
new_h = int(h * (PRINTHEAD_PX / w))
|
||||||
|
if new_h > max_rows:
|
||||||
|
log.warning("Image height %dpx exceeds max %dpx, cropping bottom", new_h, max_rows)
|
||||||
|
new_h = max_rows
|
||||||
|
img = img.resize((PRINTHEAD_PX, new_h), Image.LANCZOS)
|
||||||
|
img = img.point(lambda x: 1 if x < 128 else 0, "1")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_raster(img: Image.Image) -> bytes:
|
||||||
|
"""Pack 1-bit image into raw raster bytes, MSB first."""
|
||||||
|
if img.mode != "1":
|
||||||
|
raise ValueError(f"Expected mode '1', got '{img.mode}'")
|
||||||
|
if img.width != PRINTHEAD_PX:
|
||||||
|
raise ValueError(f"Expected width {PRINTHEAD_PX}, got {img.width}")
|
||||||
|
return img.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def text_to_image(text: str, font_size: int = 24, label_height: int = 240) -> Image.Image:
|
||||||
|
"""Render text in landscape, then rotate 90 degrees for label printing."""
|
||||||
|
canvas_w = label_height
|
||||||
|
canvas_h = PRINTHEAD_PX
|
||||||
|
img = Image.new("L", (canvas_w, canvas_h), 255)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
font = ImageFont.load_default(size=font_size)
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
x = (canvas_w - tw) // 2
|
||||||
|
y = (canvas_h - th) // 2
|
||||||
|
draw.text((x, y), text, fill=0, font=font)
|
||||||
|
|
||||||
|
img = img.rotate(90, expand=True)
|
||||||
|
return img
|
||||||
@@ -6,17 +6,63 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
|
|||||||
96px wide printhead (12 bytes/row), 203 DPI, prints 1-bit raster images.
|
96px wide printhead (12 bytes/row), 203 DPI, prints 1-bit raster images.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
from collections.abc import AsyncGenerator
|
||||||
import sys
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
# --- BLE identifiers ---
|
||||||
|
|
||||||
PRINTER_NAME_PREFIXES = ("FICHERO", "D11s_")
|
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 = 200
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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:
|
async def find_printer() -> str:
|
||||||
"""Scan BLE for a Fichero/D11s printer. Returns the address."""
|
"""Scan BLE for a Fichero/D11s printer. Returns the address."""
|
||||||
@@ -26,21 +72,10 @@ async def find_printer() -> str:
|
|||||||
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
|
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
|
||||||
print(f" Found {d.name} at {d.address}")
|
print(f" Found {d.name} at {d.address}")
|
||||||
return d.address
|
return d.address
|
||||||
print(" ERROR: No Fichero/D11s printer found. Is it turned on?")
|
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 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_PX = 96
|
# --- Status ---
|
||||||
BYTES_PER_ROW = PRINTHEAD_PX // 8 # 12
|
|
||||||
CHUNK_SIZE = 200
|
|
||||||
|
|
||||||
# Paper types for 10 FF 84 00 nn
|
|
||||||
PAPER_GAP = 0x00
|
|
||||||
PAPER_BLACK_MARK = 0x01
|
|
||||||
PAPER_CONTINUOUS = 0x02
|
|
||||||
|
|
||||||
|
|
||||||
class PrinterStatus:
|
class PrinterStatus:
|
||||||
@@ -55,7 +90,7 @@ class PrinterStatus:
|
|||||||
self.overheated = bool(byte & 0x10 or byte & 0x40)
|
self.overheated = bool(byte & 0x10 or byte & 0x40)
|
||||||
self.charging = bool(byte & 0x20)
|
self.charging = bool(byte & 0x20)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
flags = []
|
flags = []
|
||||||
if self.printing:
|
if self.printing:
|
||||||
flags.append("printing")
|
flags.append("printing")
|
||||||
@@ -76,20 +111,25 @@ class PrinterStatus:
|
|||||||
return not (self.cover_open or self.no_paper or self.overheated)
|
return not (self.cover_open or self.no_paper or self.overheated)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Client ---
|
||||||
|
|
||||||
|
|
||||||
class PrinterClient:
|
class PrinterClient:
|
||||||
def __init__(self, client: BleakClient):
|
def __init__(self, client: BleakClient):
|
||||||
self.client = client
|
self.client = client
|
||||||
self._buf = bytearray()
|
self._buf = bytearray()
|
||||||
self._event = asyncio.Event()
|
self._event = asyncio.Event()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
def _on_notify(self, _char: BleakGATTCharacteristic, data: bytearray):
|
def _on_notify(self, _char: BleakGATTCharacteristic, data: bytearray) -> None:
|
||||||
self._buf.extend(data)
|
self._buf.extend(data)
|
||||||
self._event.set()
|
self._event.set()
|
||||||
|
|
||||||
async def start(self):
|
async def start(self) -> None:
|
||||||
await self.client.start_notify(NOTIFY_UUID, self._on_notify)
|
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 def send(self, data: bytes, wait: bool = False, timeout: float = 2.0) -> bytes:
|
||||||
|
async with self._lock:
|
||||||
if wait:
|
if wait:
|
||||||
self._buf.clear()
|
self._buf.clear()
|
||||||
self._event.clear()
|
self._event.clear()
|
||||||
@@ -97,16 +137,16 @@ class PrinterClient:
|
|||||||
if wait:
|
if wait:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._event.wait(), timeout=timeout)
|
await asyncio.wait_for(self._event.wait(), timeout=timeout)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(DELAY_NOTIFY_EXTRA)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
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):
|
async def send_chunked(self, data: bytes, chunk_size: int = CHUNK_SIZE) -> None:
|
||||||
for i in range(0, len(data), chunk_size):
|
for i in range(0, len(data), chunk_size):
|
||||||
chunk = data[i : i + chunk_size]
|
chunk = data[i : i + chunk_size]
|
||||||
await self.client.write_gatt_char(WRITE_UUID, chunk, response=False)
|
await self.client.write_gatt_char(WRITE_UUID, chunk, response=False)
|
||||||
await asyncio.sleep(0.02)
|
await asyncio.sleep(DELAY_CHUNK_GAP)
|
||||||
|
|
||||||
# --- Info commands (all tested and confirmed on D11s fw 2.4.6) ---
|
# --- Info commands (all tested and confirmed on D11s fw 2.4.6) ---
|
||||||
|
|
||||||
@@ -190,18 +230,18 @@ class PrinterClient:
|
|||||||
|
|
||||||
# --- Print control (AiYin-specific, from decompiled APK) ---
|
# --- Print control (AiYin-specific, from decompiled APK) ---
|
||||||
|
|
||||||
async def wakeup(self):
|
async def wakeup(self) -> None:
|
||||||
await self.send(b"\x00" * 12)
|
await self.send(b"\x00" * 12)
|
||||||
|
|
||||||
async def enable(self):
|
async def enable(self) -> None:
|
||||||
"""AiYin enable: 10 FF FE 01 (NOT 10 FF F1 03)."""
|
"""AiYin enable: 10 FF FE 01 (NOT 10 FF F1 03)."""
|
||||||
await self.send(bytes([0x10, 0xFF, 0xFE, 0x01]))
|
await self.send(bytes([0x10, 0xFF, 0xFE, 0x01]))
|
||||||
|
|
||||||
async def feed_dots(self, dots: int):
|
async def feed_dots(self, dots: int) -> None:
|
||||||
"""Feed paper forward by n dots."""
|
"""Feed paper forward by n dots."""
|
||||||
await self.send(bytes([0x1B, 0x4A, dots & 0xFF]))
|
await self.send(bytes([0x1B, 0x4A, dots & 0xFF]))
|
||||||
|
|
||||||
async def form_feed(self):
|
async def form_feed(self) -> None:
|
||||||
"""Position to next label."""
|
"""Position to next label."""
|
||||||
await self.send(bytes([0x1D, 0x0C]))
|
await self.send(bytes([0x1D, 0x0C]))
|
||||||
|
|
||||||
@@ -226,201 +266,10 @@ class PrinterClient:
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def connect(address=None):
|
async def connect(address: str | None = None) -> AsyncGenerator[PrinterClient, None]:
|
||||||
"""Discover printer, connect, and yield a ready PrinterClient."""
|
"""Discover printer, connect, and yield a ready PrinterClient."""
|
||||||
addr = address or await find_printer()
|
addr = address or await find_printer()
|
||||||
async with BleakClient(addr) as client:
|
async with BleakClient(addr) as client:
|
||||||
pc = PrinterClient(client)
|
pc = PrinterClient(client)
|
||||||
await pc.start()
|
await pc.start()
|
||||||
yield pc
|
yield pc
|
||||||
|
|
||||||
|
|
||||||
# --- Image handling ---
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_image(img: Image.Image, max_rows: int = 240) -> Image.Image:
|
|
||||||
"""Convert any image to 96px wide, 1-bit, black on white."""
|
|
||||||
img = img.convert("L")
|
|
||||||
w, h = img.size
|
|
||||||
new_h = int(h * (PRINTHEAD_PX / w))
|
|
||||||
if new_h > max_rows:
|
|
||||||
new_h = max_rows
|
|
||||||
img = img.resize((PRINTHEAD_PX, new_h), Image.LANCZOS)
|
|
||||||
img = img.point(lambda x: 1 if x < 128 else 0, "1")
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def image_to_raster(img: Image.Image) -> bytes:
|
|
||||||
"""Pack 1-bit image into raw raster bytes, MSB first."""
|
|
||||||
return img.tobytes()
|
|
||||||
|
|
||||||
|
|
||||||
def text_to_image(text: str, label_height: int = 240) -> Image.Image:
|
|
||||||
"""Render text in landscape, then rotate 90 degrees for label printing."""
|
|
||||||
canvas_w = label_height
|
|
||||||
canvas_h = PRINTHEAD_PX
|
|
||||||
img = Image.new("L", (canvas_w, canvas_h), 255)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
font = ImageFont.load_default(size=24)
|
|
||||||
|
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
|
||||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
||||||
x = (canvas_w - tw) // 2
|
|
||||||
y = (canvas_h - th) // 2
|
|
||||||
draw.text((x, y), text, fill=0, font=font)
|
|
||||||
|
|
||||||
img = img.rotate(90, expand=True)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
# --- Print sequence (from decompiled Fichero APK: AiYinNormalDevice) ---
|
|
||||||
|
|
||||||
|
|
||||||
async def do_print(
|
|
||||||
pc: PrinterClient,
|
|
||||||
img: Image.Image,
|
|
||||||
density: int = 1,
|
|
||||||
paper: int = PAPER_GAP,
|
|
||||||
copies: int = 1,
|
|
||||||
):
|
|
||||||
img = prepare_image(img)
|
|
||||||
rows = img.height
|
|
||||||
raster = image_to_raster(img)
|
|
||||||
|
|
||||||
print(f" Image: {img.width}x{rows}, {len(raster)} bytes, {copies} copies")
|
|
||||||
|
|
||||||
status = await pc.get_status()
|
|
||||||
if not status.ok:
|
|
||||||
print(f" ERROR: printer not ready ({status})")
|
|
||||||
return False
|
|
||||||
|
|
||||||
await pc.set_density(density)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
for copy_num in range(copies):
|
|
||||||
if copies > 1:
|
|
||||||
print(f" Copy {copy_num + 1}/{copies}...")
|
|
||||||
|
|
||||||
# AiYin print sequence (from decompiled APK)
|
|
||||||
await pc.set_paper_type(paper)
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
await pc.wakeup()
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
await pc.enable()
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
# Raster image: GS v 0 m xL xH yL yH <data>
|
|
||||||
yl = rows & 0xFF
|
|
||||||
yh = (rows >> 8) & 0xFF
|
|
||||||
header = bytes([0x1D, 0x76, 0x30, 0x00, BYTES_PER_ROW, 0x00, yl, yh])
|
|
||||||
await pc.send_chunked(header + raster)
|
|
||||||
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
await pc.form_feed()
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
ok = await pc.stop_print()
|
|
||||||
if not ok:
|
|
||||||
print(" WARNING: no OK/0xAA from stop command")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# --- CLI ---
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_info(args):
|
|
||||||
async with connect(args.address) as pc:
|
|
||||||
info = await pc.get_info()
|
|
||||||
for k, v in info.items():
|
|
||||||
print(f" {k}: {v}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
all_info = await pc.get_all_info()
|
|
||||||
for k, v in all_info.items():
|
|
||||||
print(f" {k}: {v}")
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_status(args):
|
|
||||||
async with connect(args.address) as pc:
|
|
||||||
status = await pc.get_status()
|
|
||||||
print(f" Status: {status}")
|
|
||||||
print(f" Raw: 0x{status.raw:02X} ({status.raw:08b})")
|
|
||||||
print(f" printing={status.printing} cover_open={status.cover_open} "
|
|
||||||
f"no_paper={status.no_paper} low_battery={status.low_battery} "
|
|
||||||
f"overheated={status.overheated} charging={status.charging}")
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_text(args):
|
|
||||||
text = " ".join(args.text)
|
|
||||||
img = text_to_image(text)
|
|
||||||
async with connect(args.address) as pc:
|
|
||||||
print(f'Printing "{text}"...')
|
|
||||||
ok = await do_print(pc, img, args.density, copies=args.copies)
|
|
||||||
print("Done." if ok else "FAILED.")
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_image(args):
|
|
||||||
img = Image.open(args.path)
|
|
||||||
async with connect(args.address) as pc:
|
|
||||||
print(f"Printing {args.path}...")
|
|
||||||
ok = await do_print(pc, img, args.density, copies=args.copies)
|
|
||||||
print("Done." if ok else "FAILED.")
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_set(args):
|
|
||||||
async with connect(args.address) as pc:
|
|
||||||
if args.setting == "density":
|
|
||||||
ok = await pc.set_density(int(args.value))
|
|
||||||
print(f" Set density={args.value}: {'OK' if ok else 'FAILED'}")
|
|
||||||
elif args.setting == "shutdown":
|
|
||||||
ok = await pc.set_shutdown_time(int(args.value))
|
|
||||||
print(f" Set shutdown={args.value}min: {'OK' if ok else 'FAILED'}")
|
|
||||||
elif args.setting == "paper":
|
|
||||||
types = {"gap": 0, "black": 1, "continuous": 2}
|
|
||||||
ok = await pc.set_paper_type(types.get(args.value, int(args.value)))
|
|
||||||
print(f" Set paper={args.value}: {'OK' if ok else 'FAILED'}")
|
|
||||||
else:
|
|
||||||
print(f" Unknown setting: {args.setting}")
|
|
||||||
print(" Available: density (0-2), shutdown (minutes), paper (gap/black/continuous)")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Fichero D11s Label Printer")
|
|
||||||
parser.add_argument("--address", default=os.environ.get("FICHERO_ADDR"),
|
|
||||||
help="BLE address (skip scanning, or set FICHERO_ADDR)")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
p_info = sub.add_parser("info", help="Show device info")
|
|
||||||
p_info.set_defaults(func=cmd_info)
|
|
||||||
|
|
||||||
p_status = sub.add_parser("status", help="Show detailed status")
|
|
||||||
p_status.set_defaults(func=cmd_status)
|
|
||||||
|
|
||||||
p_text = sub.add_parser("text", help="Print text label")
|
|
||||||
p_text.add_argument("text", nargs="+", help="Text to print")
|
|
||||||
p_text.add_argument("--density", type=int, default=1, choices=[0, 1, 2],
|
|
||||||
help="Print density: 0=light, 1=medium, 2=thick")
|
|
||||||
p_text.add_argument("--copies", type=int, default=1, help="Number of copies")
|
|
||||||
p_text.set_defaults(func=cmd_text)
|
|
||||||
|
|
||||||
p_image = sub.add_parser("image", help="Print image file")
|
|
||||||
p_image.add_argument("path", help="Path to image file")
|
|
||||||
p_image.add_argument("--density", type=int, default=1, choices=[0, 1, 2],
|
|
||||||
help="Print density: 0=light, 1=medium, 2=thick")
|
|
||||||
p_image.add_argument("--copies", type=int, default=1, help="Number of copies")
|
|
||||||
p_image.set_defaults(func=cmd_image)
|
|
||||||
|
|
||||||
p_set = sub.add_parser("set", help="Change printer settings")
|
|
||||||
p_set.add_argument("setting", choices=["density", "shutdown", "paper"],
|
|
||||||
help="Setting to change")
|
|
||||||
p_set.add_argument("value", help="New value")
|
|
||||||
p_set.set_defaults(func=cmd_set)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
asyncio.run(args.func(args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -8,5 +8,12 @@ dependencies = [
|
|||||||
"pillow",
|
"pillow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["fichero"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
fichero = "printer:main"
|
fichero = "fichero.cli:main"
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -73,7 +73,7 @@ wheels = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "fichero-printer"
|
name = "fichero-printer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bleak" },
|
{ name = "bleak" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
|
|||||||
Reference in New Issue
Block a user