diff --git a/README.md b/README.md index 3e657b4..f9fad74 100644 --- a/README.md +++ b/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: ``` -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: @@ -78,45 +78,68 @@ export FICHERO_ADDR=AA:BB:CC:DD:EE:FF 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 ``` -uv run printer.py --help +uv run fichero --help ``` ### Printing ``` -uv run printer.py text "Hello World" -uv run printer.py text "Fragile" --density 2 --copies 3 -uv run printer.py image label.png -uv run printer.py image label.png --density 1 --copies 2 +uv run fichero text "Hello World" +uv run fichero text "Fragile" --density 2 --copies 3 +uv run fichero text "Big Label" --font-size 40 --label-height 180 +uv run fichero image label.png +uv run fichero image label.png --density 1 --copies 2 ``` Density: 0=light, 1=medium (default), 2=thick. +Text labels accept `--font-size` (default 24) and `--label-height` in pixels (default 240). + ### Device info ``` -uv run printer.py info -uv run printer.py status +uv run fichero info +uv run fichero status ``` ### Settings ``` -uv run printer.py set density 2 -uv run printer.py set shutdown 30 -uv run printer.py set paper gap +uv run fichero set density 2 +uv run fichero set shutdown 30 +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. -- `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. +## 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 See [docs/PROTOCOL.md](docs/PROTOCOL.md) for the full command reference, print sequence, and how this was reverse-engineered. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index bf338e9..dad2b48 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -181,22 +181,6 @@ Fichero-branded printers: - Fichero 4575 -> DP_D1H (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 1. BLE enumeration with bleak to find services and characteristics diff --git a/fichero/__init__.py b/fichero/__init__.py new file mode 100644 index 0000000..6aa9fad --- /dev/null +++ b/fichero/__init__.py @@ -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", +] diff --git a/fichero/cli.py b/fichero/cli.py new file mode 100644 index 0000000..63bfe11 --- /dev/null +++ b/fichero/cli.py @@ -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 + 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() diff --git a/fichero/imaging.py b/fichero/imaging.py new file mode 100644 index 0000000..dc271a5 --- /dev/null +++ b/fichero/imaging.py @@ -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 diff --git a/printer.py b/fichero/printer.py similarity index 50% rename from printer.py rename to fichero/printer.py index 264baed..996d169 100644 --- a/printer.py +++ b/fichero/printer.py @@ -6,17 +6,63 @@ Device class: AiYinNormalDevice (LuckPrinter SDK) 96px wide printhead (12 bytes/row), 203 DPI, prints 1-bit raster images. """ -import argparse import asyncio -import os -import sys +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner -from PIL import Image, ImageDraw, ImageFont + +# --- 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 = 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: """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): print(f" Found {d.name} at {d.address}") return d.address - print(" ERROR: No Fichero/D11s printer found. Is it turned on?") - sys.exit(1) + raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") -# 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 -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 +# --- Status --- class PrinterStatus: @@ -55,7 +90,7 @@ class PrinterStatus: self.overheated = bool(byte & 0x10 or byte & 0x40) self.charging = bool(byte & 0x20) - def __str__(self): + def __str__(self) -> str: flags = [] if self.printing: flags.append("printing") @@ -76,37 +111,42 @@ class PrinterStatus: return not (self.cover_open or self.no_paper or self.overheated) +# --- Client --- + + class PrinterClient: def __init__(self, client: BleakClient): self.client = client self._buf = bytearray() 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._event.set() - async def start(self): + 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: - 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(0.05) - except asyncio.TimeoutError: - pass + 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 = CHUNK_SIZE): + async def send_chunked(self, data: bytes, chunk_size: int = CHUNK_SIZE) -> None: for i in range(0, len(data), chunk_size): chunk = data[i : i + chunk_size] await self.client.write_gatt_char(WRITE_UUID, chunk, response=False) - await asyncio.sleep(0.02) + await asyncio.sleep(DELAY_CHUNK_GAP) # --- 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) --- - async def wakeup(self): + async def wakeup(self) -> None: 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).""" 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.""" await self.send(bytes([0x1B, 0x4A, dots & 0xFF])) - async def form_feed(self): + async def form_feed(self) -> None: """Position to next label.""" await self.send(bytes([0x1D, 0x0C])) @@ -226,201 +266,10 @@ class PrinterClient: @asynccontextmanager -async def connect(address=None): +async def connect(address: str | None = None) -> AsyncGenerator[PrinterClient, None]: """Discover printer, connect, and yield a ready PrinterClient.""" addr = address or await find_printer() async with BleakClient(addr) as client: pc = PrinterClient(client) await pc.start() yield pc - - -# --- 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 - 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() diff --git a/pyproject.toml b/pyproject.toml index 53d75bc..5e1e4bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,5 +8,12 @@ dependencies = [ "pillow", ] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["fichero"] + [project.scripts] -fichero = "printer:main" +fichero = "fichero.cli:main" diff --git a/uv.lock b/uv.lock index e561bf0..6d11059 100644 --- a/uv.lock +++ b/uv.lock @@ -73,7 +73,7 @@ wheels = [ [[package]] name = "fichero-printer" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "bleak" }, { name = "pillow" },