diff --git a/CHANGELOG.md b/CHANGELOG.md index 3990f9e..fbde396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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. +## [0.1.23] - 2026-03-08 + +### Changed + +- Updated the Home Assistant add-on's `Dockerfile` to install the main library as a package, completing the project structure refactoring. +- Added `python-multipart` as an explicit dependency for the API server. + +## [0.1.22] - 2026-03-08 + +### Changed + +- **Refactored Project Structure**: Eliminated duplicated code by converting the project into an installable Python package. The Home Assistant add-on now installs the main library as a dependency instead of using a vendored copy, improving maintainability and preventing sync issues. + +## [0.1.21] - 2026-03-08 + +### Fixed +- Synchronized the Home Assistant add-on's source code (`fichero_printer/fichero/`) with the main library to fix stale code and version mismatch issues. + ## [0.1.20] - 2026-03-08 ### Changed diff --git a/fichero/api.py b/fichero/api.py index 93c27ab..6c41d8a 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -75,7 +75,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001 app = FastAPI( title="Fichero Printer API", description="REST API for the Fichero D11s (AiYin) thermal label printer.", - version="0.1.20", + version="0.1.23", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index 9b9a31a..668b033 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -1,32 +1,27 @@ ARG BUILD_FROM FROM $BUILD_FROM -# Only dbus-dev needed to talk to the HOST BlueZ via D-Bus (host_dbus: true). +# Install build tools for Python packages that need compilation (numpy, pillow) +# and dbus-dev for Bleak to communicate with the host's BlueZ via D-Bus. # Do NOT install bluez here - we use the host BlueZ, not our own. RUN apk add --no-cache \ bash \ python3 \ py3-pip \ - py3-numpy \ - py3-pillow \ - dbus-dev + dbus-dev \ + build-base -# Pure-Python packages (bleak uses dbus-fast internally, no C compiler needed) -RUN pip3 install --no-cache-dir --break-system-packages \ - "bleak>=0.21" \ - "fastapi>=0.111" \ - "uvicorn[standard]>=0.29" \ - "python-multipart>=0.0.9" - -# Copy the fichero Python package into the container +# Copy the entire project into the container. +# This requires the Docker build context to be the root of the repository. WORKDIR /app -COPY fichero/ /app/fichero/ +COPY . . -# Make the package importable without installation -ENV PYTHONPATH=/app +# Install the fichero-printer package and all its dependencies from pyproject.toml. +# This makes the `fichero` and `fichero-server` commands available system-wide. +RUN pip3 install --no-cache-dir --break-system-packages . # Copy startup script and normalise line endings (Windows CRLF -> LF) -COPY run.sh /usr/bin/run.sh +COPY fichero_printer/run.sh /usr/bin/run.sh RUN sed -i 's/\r//' /usr/bin/run.sh && chmod +x /usr/bin/run.sh CMD ["/usr/bin/run.sh"] diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index f36af81..7a04566 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.15" +version: "0.1.23" slug: "fichero_printer" description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth" url: "https://git.leuschner.dev/Tobias/Fichero" diff --git a/fichero_printer/fichero/__init__.py b/fichero_printer/fichero/__init__.py deleted file mode 100644 index 559f7b5..0000000 --- a/fichero_printer/fichero/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Fichero D11s thermal label printer - BLE + Classic Bluetooth interface.""" - -from fichero.printer import ( - RFCOMM_CHANNEL, - PrinterClient, - PrinterError, - PrinterNotFound, - PrinterNotReady, - PrinterStatus, - PrinterTimeout, - RFCOMMClient, - connect, -) - -__all__ = [ - "RFCOMM_CHANNEL", - "PrinterClient", - "PrinterError", - "PrinterNotFound", - "PrinterNotReady", - "PrinterStatus", - "PrinterTimeout", - "RFCOMMClient", - "connect", -] diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py deleted file mode 100644 index 3e2d06b..0000000 --- a/fichero_printer/fichero/api.py +++ /dev/null @@ -1,829 +0,0 @@ -"""HTTP REST API for the Fichero D11s thermal label printer. - -Start with: - fichero-server [--host HOST] [--port PORT] -or: - python -m fichero.api - -Endpoints: - GET /status – Printer status - GET /info – Printer info (model, firmware, battery, …) - POST /print/text – Print a text label - POST /print/image – Print an uploaded image file -""" - -from __future__ import annotations - -import argparse -import asyncio -import io -import os -from contextlib import asynccontextmanager -from typing import Annotated - -from fastapi import FastAPI, File, Form, HTTPException, UploadFile -from fastapi.middleware.cors import CORSMiddleware -from fastapi.openapi.docs import get_swagger_ui_html -from fastapi.responses import HTMLResponse, RedirectResponse -from PIL import Image - -from fichero.cli import DOTS_PER_MM, do_print -from fichero.imaging import text_to_image -from fichero.printer import ( - PAPER_GAP, - PrinterError, - PrinterNotFound, - PrinterTimeout, - connect, - find_printer, -) - -# --------------------------------------------------------------------------- -# Global connection settings (env vars or CLI flags at startup) -# --------------------------------------------------------------------------- - -_DEFAULT_ADDRESS: str | None = os.environ.get("FICHERO_ADDR") -_DEFAULT_CLASSIC: bool = os.environ.get("FICHERO_TRANSPORT", "").lower() == "classic" -_DEFAULT_CHANNEL: int = int(os.environ.get("FICHERO_CHANNEL", "1")) - -_PAPER_MAP = {"gap": 0, "black": 1, "continuous": 2} - - -def _parse_paper(value: str) -> int: - if value in _PAPER_MAP: - return _PAPER_MAP[value] - try: - val = int(value) - if 0 <= val <= 2: - return val - except ValueError: - pass - raise HTTPException(status_code=422, detail=f"Invalid paper type '{value}'. Use gap, black, continuous or 0-2.") - - -# --------------------------------------------------------------------------- -# FastAPI app -# --------------------------------------------------------------------------- - -@asynccontextmanager -async def lifespan(app: FastAPI): # noqa: ARG001 - yield - - -app = FastAPI( - title="Fichero Printer API", - description="REST API for the Fichero D11s (AiYin) thermal label printer.", - version="0.1.13", - lifespan=lifespan, - docs_url=None, - redoc_url=None, -) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - - -def _address(address: str | None) -> str | None: - """Return the effective BLE address (request value overrides env default).""" - return address or _DEFAULT_ADDRESS - - -def _ui_html() -> str: - default_address = _DEFAULT_ADDRESS or "" - default_transport = "classic" if _DEFAULT_CLASSIC else "ble" - return f""" - - - - - Fichero Printer - - - - -
- FicheroPrinter -
- - - Unknown - - -
-
- -
- - -
-

Connection

- - -
- - -
- -
-
- - -
-
- - -
-
- -
- - -
-
- - -
-

Response

-
Waiting for a command…
-
- - -
-

Print text

- - - - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - - - -
- -
-
- - -
-

Print image

- - - - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- -
- - -
- -
- -
-
- -
- - - - - -""" - - -# --------------------------------------------------------------------------- -# Endpoints -# --------------------------------------------------------------------------- - -@app.get("/", include_in_schema=False, response_class=HTMLResponse) -async def root(): - """Serve a compact printer UI for Home Assistant.""" - return HTMLResponse(_ui_html()) - - -@app.get("/docs", include_in_schema=False) -async def docs(): - """Serve Swagger UI with ingress-safe relative OpenAPI URL.""" - return get_swagger_ui_html( - openapi_url="openapi.json", - title=f"{app.title} - Swagger UI", - ) - - -@app.get( - "/scan", - summary="Scan for printer", - response_description="BLE address of the discovered printer", -) -async def scan_printer(): - """Scan for a Fichero/D11s printer via BLE and return its address.""" - try: - address = await find_printer() - except PrinterNotFound as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return {"address": address} - - -@app.get( - "/status", - summary="Get printer status", - response_description="Current printer status flags", -) -async def get_status( - address: str | None = None, - classic: bool = _DEFAULT_CLASSIC, - channel: int = _DEFAULT_CHANNEL, -): - """Return the real-time status of the printer (paper, battery, heat, …).""" - try: - async with connect(_address(address), classic=classic, channel=channel) as pc: - status = await pc.get_status() - except PrinterNotFound as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except PrinterTimeout as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc - except PrinterError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - return { - "ok": status.ok, - "printing": status.printing, - "cover_open": status.cover_open, - "no_paper": status.no_paper, - "low_battery": status.low_battery, - "overheated": status.overheated, - "charging": status.charging, - "raw": status.raw, - } - - -@app.get( - "/info", - summary="Get printer info", - response_description="Model, firmware, serial number and battery level", -) -async def get_info( - address: str | None = None, - classic: bool = _DEFAULT_CLASSIC, - channel: int = _DEFAULT_CHANNEL, -): - """Return static and dynamic printer information.""" - try: - async with connect(_address(address), classic=classic, channel=channel) as pc: - info = await pc.get_info() - info.update(await pc.get_all_info()) - except PrinterNotFound as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except PrinterTimeout as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc - except PrinterError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - return info - - -@app.post( - "/print/text", - summary="Print a text label", - status_code=200, -) -async def print_text( - text: Annotated[str, Form(description="Text to print on the label")], - density: Annotated[int, Form(description="Print density: 0=light, 1=medium, 2=dark", ge=0, le=2)] = 2, - paper: Annotated[str, Form(description="Paper type: gap, black, or continuous")] = "gap", - copies: Annotated[int, Form(description="Number of copies", ge=1, le=99)] = 1, - font_size: Annotated[int, Form(description="Font size in points", ge=6, le=200)] = 30, - label_length: Annotated[int | None, Form(description="Label length in mm (overrides label_height)", ge=5, le=500)] = None, - label_height: Annotated[int, Form(description="Label height in pixels", ge=40, le=4000)] = 240, - address: Annotated[str | None, Form(description="BLE address (optional, overrides FICHERO_ADDR)")] = None, - classic: Annotated[bool, Form(description="Use Classic Bluetooth RFCOMM")] = _DEFAULT_CLASSIC, - channel: Annotated[int, Form(description="RFCOMM channel")] = _DEFAULT_CHANNEL, -): - """Print a plain-text label. - - The text is rendered as a 96 px wide, 1-bit image and sent to the printer. - """ - paper_val = _parse_paper(paper) - max_rows = (label_length * DOTS_PER_MM) if label_length is not None else label_height - - img = text_to_image(text, font_size=font_size, label_height=max_rows) - - try: - async with connect(_address(address), classic=classic, channel=channel) as pc: - ok = await do_print(pc, img, density=density, paper=paper_val, copies=copies, - dither=False, max_rows=max_rows) - except PrinterNotFound as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except PrinterTimeout as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc - except PrinterError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - if not ok: - raise HTTPException(status_code=502, detail="Printer did not confirm completion.") - - return {"ok": True, "copies": copies, "text": text} - - -@app.post( - "/print/image", - summary="Print an image", - status_code=200, -) -async def print_image( - file: Annotated[UploadFile, File(description="Image file to print (PNG, JPEG, BMP, …)")], - density: Annotated[int, Form(description="Print density: 0=light, 1=medium, 2=dark", ge=0, le=2)] = 2, - paper: Annotated[str, Form(description="Paper type: gap, black, or continuous")] = "gap", - copies: Annotated[int, Form(description="Number of copies", ge=1, le=99)] = 1, - dither: Annotated[bool, Form(description="Apply Floyd-Steinberg dithering")] = True, - label_length: Annotated[int | None, Form(description="Max label length in mm (overrides label_height)", ge=5, le=500)] = None, - label_height: Annotated[int, Form(description="Max label height in pixels", ge=40, le=4000)] = 240, - address: Annotated[str | None, Form(description="BLE address (optional, overrides FICHERO_ADDR)")] = None, - classic: Annotated[bool, Form(description="Use Classic Bluetooth RFCOMM")] = _DEFAULT_CLASSIC, - channel: Annotated[int, Form(description="RFCOMM channel")] = _DEFAULT_CHANNEL, -): - """Print an image file. - - The image is resized to 96 px wide, optionally dithered to 1-bit, and sent to the printer. - Supported formats: PNG, JPEG, BMP, GIF, TIFF, WEBP. - """ - # Validate content type loosely — Pillow will raise on unsupported data - data = await file.read() - if not data: - raise HTTPException(status_code=422, detail="Uploaded file is empty.") - - try: - img = Image.open(io.BytesIO(data)) - img.load() # ensure the image is fully decoded - except Exception as exc: - raise HTTPException(status_code=422, detail=f"Cannot decode image: {exc}") from exc - - paper_val = _parse_paper(paper) - max_rows = (label_length * DOTS_PER_MM) if label_length is not None else label_height - - try: - async with connect(_address(address), classic=classic, channel=channel) as pc: - ok = await do_print(pc, img, density=density, paper=paper_val, copies=copies, - dither=dither, max_rows=max_rows) - except PrinterNotFound as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except PrinterTimeout as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc - except PrinterError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - if not ok: - raise HTTPException(status_code=502, detail="Printer did not confirm completion.") - - return {"ok": True, "copies": copies, "filename": file.filename} - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -def main() -> None: - """Start the Fichero HTTP API server.""" - global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL - - try: - import uvicorn # noqa: PLC0415 - except ImportError: - print("ERROR: uvicorn is required to run the API server.") - print("Install it with: pip install 'fichero-printer[api]'") - raise SystemExit(1) from None - - parser = argparse.ArgumentParser(description="Fichero Printer API Server") - parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") - parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)") - parser.add_argument("--address", default=_DEFAULT_ADDRESS, metavar="BLE_ADDR", - help="Default BLE address (or set FICHERO_ADDR env var)") - parser.add_argument("--classic", action="store_true", default=_DEFAULT_CLASSIC, - help="Default to Classic Bluetooth RFCOMM") - parser.add_argument("--channel", type=int, default=_DEFAULT_CHANNEL, - help="Default RFCOMM channel (default: 1)") - parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)") - args = parser.parse_args() - - # Push CLI overrides into module-level defaults so all handlers pick them up - _DEFAULT_ADDRESS = args.address - _DEFAULT_CLASSIC = args.classic - _DEFAULT_CHANNEL = args.channel - - # Pass the app object directly when not reloading so that the module-level - # globals (_DEFAULT_ADDRESS etc.) set above are visible to the handlers. - # The string form "fichero.api:app" is required for --reload only, because - # uvicorn's reloader needs to re-import the module in a worker process. - uvicorn.run( - "fichero.api:app" if args.reload else app, - host=args.host, - port=args.port, - reload=args.reload, - ) - - -if __name__ == "__main__": - main() diff --git a/fichero_printer/fichero/cli.py b/fichero_printer/fichero/cli.py deleted file mode 100644 index 978e694..0000000 --- a/fichero_printer/fichero/cli.py +++ /dev/null @@ -1,251 +0,0 @@ -"""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, -) - -DOTS_PER_MM = 8 # 203 DPI - - -def _resolve_label_height(args: argparse.Namespace) -> int: - """Return label height in pixels from --label-length (mm) or --label-height (px).""" - if args.label_length is not None: - return args.label_length * DOTS_PER_MM - return args.label_height - - -async def do_print( - pc: PrinterClient, - img: Image.Image, - density: int = 1, - paper: int = PAPER_GAP, - copies: int = 1, - dither: bool = True, - max_rows: int = 240, -) -> bool: - img = prepare_image(img, max_rows=max_rows, dither=dither) - rows = img.height - raster = image_to_raster(img) - - print(f" Image: {img.width}x{rows}, {len(raster)} bytes, {copies} copies") - - 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}...") - - # Check status before each copy (matches decompiled app behaviour) - status = await pc.get_status() - if not status.ok: - raise PrinterNotReady(f"Printer not ready: {status}") - - # 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, classic=args.classic, channel=args.channel) 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, classic=args.classic, channel=args.channel) 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) - label_h = _resolve_label_height(args) - img = text_to_image(text, font_size=args.font_size, label_height=label_h) - async with connect(args.address, classic=args.classic, channel=args.channel) as pc: - print(f'Printing "{text}"...') - ok = await do_print(pc, img, args.density, paper=args.paper, - copies=args.copies, dither=False, max_rows=label_h) - print("Done." if ok else "FAILED.") - - -async def cmd_image(args: argparse.Namespace) -> None: - img = Image.open(args.path) - label_h = _resolve_label_height(args) - async with connect(args.address, classic=args.classic, channel=args.channel) as pc: - print(f"Printing {args.path}...") - ok = await do_print(pc, img, args.density, paper=args.paper, - copies=args.copies, dither=not args.no_dither, - max_rows=label_h) - print("Done." if ok else "FAILED.") - - -async def cmd_set(args: argparse.Namespace) -> None: - async with connect(args.address, classic=args.classic, channel=args.channel) 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 _add_paper_arg(parser: argparse.ArgumentParser) -> None: - """Add --paper argument to a subparser.""" - parser.add_argument( - "--paper", type=str, default="gap", - help="Paper type: gap (default), black, continuous", - ) - - -def _parse_paper(value: str) -> int: - """Convert paper string/int to protocol value.""" - types = {"gap": 0, "black": 1, "continuous": 2} - if value in types: - return types[value] - try: - val = int(value) - if 0 <= val <= 2: - return val - except ValueError: - pass - print(f" WARNING: unknown paper type '{value}', using gap") - return 0 - - -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)") - parser.add_argument("--classic", action="store_true", - default=os.environ.get("FICHERO_TRANSPORT", "").lower() == "classic", - help="Use Classic Bluetooth (RFCOMM) instead of BLE (Linux only, " - "or set FICHERO_TRANSPORT=classic)") - parser.add_argument("--channel", type=int, default=1, - help="RFCOMM channel (default: 1, only used with --classic)") - 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=2, 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=30, help="Font size in points") - p_text.add_argument("--label-length", type=int, default=None, - help="Label length in mm (default: 30mm)") - p_text.add_argument("--label-height", type=int, default=240, - help="Label height in pixels (default: 240, prefer --label-length)") - _add_paper_arg(p_text) - 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=2, 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.add_argument("--no-dither", action="store_true", - help="Disable Floyd-Steinberg dithering (use simple threshold)") - p_image.add_argument("--label-length", type=int, default=None, - help="Label length in mm (default: 30mm)") - p_image.add_argument("--label-height", type=int, default=240, - help="Max image height in pixels (default: 240, prefer --label-length)") - _add_paper_arg(p_image) - 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() - - # Resolve --paper string to int for print commands - if hasattr(args, "paper") and isinstance(args.paper, str): - args.paper = _parse_paper(args.paper) - - 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_printer/fichero/imaging.py b/fichero_printer/fichero/imaging.py deleted file mode 100644 index 5d1554b..0000000 --- a/fichero_printer/fichero/imaging.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Image processing for Fichero D11s thermal label printer.""" - -import logging - -import numpy as np -from PIL import Image, ImageDraw, ImageFont, ImageOps - -from fichero.printer import PRINTHEAD_PX - -log = logging.getLogger(__name__) - - -def floyd_steinberg_dither(img: Image.Image) -> Image.Image: - """Floyd-Steinberg error-diffusion dithering to 1-bit. - - Same algorithm as PrinterImageProcessor.ditherFloydSteinberg() in the - decompiled Fichero APK: distributes quantisation error to neighbouring - pixels with weights 7/16, 3/16, 5/16, 1/16. - """ - arr = np.array(img, dtype=np.float32) - h, w = arr.shape - - for y in range(h): - for x in range(w): - old = arr[y, x] - new = 0.0 if old < 128 else 255.0 - arr[y, x] = new - err = old - new - if x + 1 < w: - arr[y, x + 1] += err * 7 / 16 - if y + 1 < h: - if x - 1 >= 0: - arr[y + 1, x - 1] += err * 3 / 16 - arr[y + 1, x] += err * 5 / 16 - if x + 1 < w: - arr[y + 1, x + 1] += err * 1 / 16 - - arr = np.clip(arr, 0, 255).astype(np.uint8) - return Image.fromarray(arr, mode="L") - - -def prepare_image( - img: Image.Image, max_rows: int = 240, dither: bool = True -) -> Image.Image: - """Convert any image to 96px wide, 1-bit, black on white. - - When *dither* is True (default), uses Floyd-Steinberg error diffusion - for better quality on photos and gradients. Set False for crisp text. - """ - img = img.convert("L") - w, h = img.size - new_h = int(h * (PRINTHEAD_PX / w)) - img = img.resize((PRINTHEAD_PX, new_h), Image.LANCZOS) - - if new_h > max_rows: - log.warning("Image height %dpx exceeds max %dpx, cropping bottom", new_h, max_rows) - img = img.crop((0, 0, PRINTHEAD_PX, max_rows)) - - img = ImageOps.autocontrast(img, cutoff=1) - - if dither: - img = floyd_steinberg_dither(img) - - # Pack to 1-bit. PIL mode "1" tobytes() uses 0-bit=black, 1-bit=white, - # but the printer wants 1-bit=black. Mapping dark->1 via point() inverts - # the PIL convention so the final packed bits match what the printer needs. - 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 = 30, label_height: int = 240) -> Image.Image: - """Render crisp 1-bit text, rotated 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) - draw.fontmode = "1" # disable antialiasing - pure 1-bit glyph rendering - - 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 - bbox[0] - y = (canvas_h - th) // 2 - bbox[1] - draw.text((x, y), text, fill=0, font=font) - - img = img.rotate(90, expand=True) - return img diff --git a/fichero_printer/fichero/printer.py b/fichero_printer/fichero/printer.py deleted file mode 100644 index 7d44601..0000000 --- a/fichero_printer/fichero/printer.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -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 -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; do not fall back to - # raw address because BlueZ may then attempt BR/EDR and fail with - # br-connection-not-supported. - devices = await BleakScanner.discover(timeout=8) - for d in devices: - if d.address and d.address.lower() == address.lower(): - return d - raise PrinterNotFound( - f"BLE device {address} not found during scan. " - "Ensure printer is on, awake, and in range." - ) - 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: - last_exc = exc - 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}'.") - else: - 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: - forced_rescan_done = True - target = await resolve_ble_target(None) - if attempt < BLE_CONNECT_RETRIES: - await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt) - continue - raise PrinterError( - "BLE connection failed (br-connection-not-supported) after LE rescan. " - "Try Classic Bluetooth with classic=true and channel=1." - ) 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.") diff --git a/fichero_printer/translations/de.yaml b/fichero_printer/translations/de.yaml new file mode 100644 index 0000000..a0bce54 --- /dev/null +++ b/fichero_printer/translations/de.yaml @@ -0,0 +1,13 @@ +configuration: + port: + name: "API-Port" + description: "Port des REST-API-Servers. Den obigen Port-Mapping-Eintrag entsprechend anpassen." + ble_address: + name: "Bluetooth-Adresse" + description: "Feste BLE-Adresse des Druckers (z.B. AA:BB:CC:DD:EE:FF). Leer lassen für automatischen Scan." + transport: + name: "Transport" + description: "Verbindungsart: 'ble' für Bluetooth Low Energy (Standard) oder 'classic' für RFCOMM." + channel: + name: "RFCOMM-Kanal" + description: "Classic-Bluetooth-RFCOMM-Kanal. Nur relevant wenn Transport auf 'classic' gesetzt ist." \ No newline at end of file diff --git a/fichero_printer/translations/en.yaml b/fichero_printer/translations/en.yaml index 0f8a2f6..49a28c1 100644 --- a/fichero_printer/translations/en.yaml +++ b/fichero_printer/translations/en.yaml @@ -1,13 +1,13 @@ configuration: port: - name: "API-Port" - description: "Port des REST-API-Servers. Den obigen Port-Mapping-Eintrag entsprechend anpassen." + name: "API Port" + description: "Port for the REST API server. Adjust the port mapping entry above accordingly." ble_address: - name: "Bluetooth-Adresse" - description: "Feste BLE-Adresse des Druckers (z.B. AA:BB:CC:DD:EE:FF). Leer lassen für automatischen Scan." + name: "Bluetooth Address" + description: "Fixed BLE address of the printer (e.g., AA:BB:CC:DD:EE:FF). Leave empty for automatic scan." transport: name: "Transport" - description: "Verbindungsart: 'ble' für Bluetooth Low Energy (Standard) oder 'classic' für RFCOMM." + description: "Connection type: 'ble' for Bluetooth Low Energy (default) or 'classic' for RFCOMM." channel: - name: "RFCOMM-Kanal" - description: "Classic-Bluetooth-RFCOMM-Kanal. Nur relevant wenn Transport auf 'classic' gesetzt ist." + name: "RFCOMM Channel" + description: "Classic Bluetooth RFCOMM channel. Only relevant if transport is set to 'classic'." diff --git a/pyproject.toml b/pyproject.toml index 59d8a60..021cdc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,31 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + [project] name = "fichero-printer" -version = "0.1.20" -description = "Fichero D11s thermal label printer - BLE CLI tool" +version = "0.1.23" +description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." +readme = "README.md" requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "0xMH"}, + {name = "Paul Kozber"}, +] dependencies = [ "bleak", "numpy", - "pillow", -] - -[project.optional-dependencies] -api = [ - "fastapi>=0.111", - "uvicorn[standard]>=0.29", + "Pillow", + "fastapi", + "uvicorn[standard]", "python-multipart>=0.0.9", ] -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["fichero"] - [project.scripts] fichero = "fichero.cli:main" fichero-server = "fichero.api:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["fichero*"] \ No newline at end of file