diff --git a/CHANGELOG.md b/CHANGELOG.md
index c39818b..217a05a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ 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.27] - 2026-03-16
+
+### Changed
+
+- **Project Structure**: Moved the `fichero` library into `fichero_printer/` to make the add-on self-contained. This simplifies the build process and removes the need for synchronization scripts.
+- Fixed invalid duplicate `version` keys in `pyproject.toml` and `config.yaml`.
+
+## [0.1.26] - 2026-03-16
+
+### Fixed
+
+- **Build Process**: Fixed `too many links` Docker build error by removing the symlink-based approach. Introduced a `sync_addon.sh` script to automate copying the library into the add-on directory, which is required for the Home Assistant build system.
+
## [0.1.25] - 2026-03-08
### Changed
diff --git a/fichero/api.py b/fichero/api.py
index 8cb18a6..e621cd0 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.25",
+ version="0.1.27",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md
index 2190451..5e9a822 100644
--- a/fichero_printer/CHANGELOG.md
+++ b/fichero_printer/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## 0.1.27
+- The `fichero` library is now part of the add-on directory, simplifying the build process and removing the need for synchronization scripts.
+
## 0.1.24
- Fixed Docker build failures by reverting to a vendored code approach. The add-on now expects the `fichero` library to be present within its directory during the build.
diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml
index 1d907ad..c2d956f 100644
--- a/fichero_printer/config.yaml
+++ b/fichero_printer/config.yaml
@@ -1,5 +1,5 @@
name: "Fichero Printer"
-version: "0.1.25"
+version: "0.1.27"
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 b/fichero_printer/fichero
deleted file mode 120000
index 43e0b2c..0000000
--- a/fichero_printer/fichero
+++ /dev/null
@@ -1 +0,0 @@
-../fichero
\ No newline at end of file
diff --git a/fichero/__init__.py b/fichero_printer/fichero/__init__.py
similarity index 100%
rename from fichero/__init__.py
rename to fichero_printer/fichero/__init__.py
diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py
new file mode 100644
index 0000000..0dace79
--- /dev/null
+++ b/fichero_printer/fichero/api.py
@@ -0,0 +1,418 @@
+"""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 re
+import os
+from contextlib import asynccontextmanager
+from pathlib import Path
+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,
+)
+
+# ---------------------------------------------------------------------------
+# 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.26",
+ 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"
+
+ try:
+ template_path = Path(__file__).parent / "index.html"
+ template = template_path.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ return "
Error: index.html not found
"
+
+ # Simple substitution for initial values
+ return (
+ template.replace("{default_address}", default_address)
+ .replace("{ble_selected}", " selected" if default_transport == "ble" else "")
+ .replace("{classic_selected}", " selected" if default_transport == "classic" else "")
+ .replace("{default_channel}", str(_DEFAULT_CHANNEL))
+ )
+
+
+# ---------------------------------------------------------------------------
+# 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(
+ "/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(
+ "/pair",
+ summary="Pair and trust a Bluetooth device",
+ status_code=200,
+)
+async def pair_device(
+ address: Annotated[str | None, Form(description="Device address (optional, overrides FICHERO_ADDR)")] = None,
+):
+ """
+ Attempt to pair and trust the device using `bluetoothctl`.
+ This is intended for setting up Classic Bluetooth connections.
+ """
+ addr = _address(address)
+ if not addr:
+ raise HTTPException(status_code=422, detail="Address is required to pair.")
+
+ # Basic validation for MAC address format to mitigate injection risk.
+ if not re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", addr, re.IGNORECASE):
+ raise HTTPException(status_code=422, detail=f"Invalid address format: {addr}")
+
+ cmd = f'echo -e "pair {addr}\\ntrust {addr}\\nquit" | bluetoothctl'
+
+ try:
+ proc = await asyncio.create_subprocess_shell(
+ cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
+ except FileNotFoundError:
+ raise HTTPException(status_code=500, detail="`bluetoothctl` command not found. Is BlueZ installed and in PATH?")
+ except asyncio.TimeoutError:
+ raise HTTPException(status_code=504, detail="`bluetoothctl` command timed out after 15 seconds.")
+
+ output = stdout.decode(errors="ignore")
+ error = stderr.decode(errors="ignore")
+
+ if "Failed to pair" in output or "not available" in output.lower():
+ raise HTTPException(status_code=502, detail=f"Pairing failed. Output: {output}. Error: {error}")
+
+ return {"ok": True, "message": "Pair/trust command sent. Check output for details.", "output": output, "error": error}
+
+
+@app.post(
+ "/unpair",
+ summary="Unpair a Bluetooth device",
+ status_code=200,
+)
+async def unpair_device(
+ address: Annotated[str | None, Form(description="Device address (optional, overrides FICHERO_ADDR)")] = None,
+):
+ """
+ Attempt to unpair the device using `bluetoothctl`.
+ """
+ addr = _address(address)
+ if not addr:
+ raise HTTPException(status_code=422, detail="Address is required to unpair.")
+
+ # Basic validation for MAC address format to mitigate injection risk.
+ if not re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", addr, re.IGNORECASE):
+ raise HTTPException(status_code=422, detail=f"Invalid address format: {addr}")
+
+ cmd = f'echo -e "remove {addr}\\nquit" | bluetoothctl'
+
+ try:
+ proc = await asyncio.create_subprocess_shell(
+ cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
+ except FileNotFoundError:
+ raise HTTPException(status_code=500, detail="`bluetoothctl` command not found. Is BlueZ installed and in PATH?")
+ except asyncio.TimeoutError:
+ raise HTTPException(status_code=504, detail="`bluetoothctl` command timed out after 15 seconds.")
+
+ output = stdout.decode(errors="ignore")
+ error = stderr.decode(errors="ignore")
+
+ if "Failed to remove" in output or "not available" in output.lower():
+ raise HTTPException(status_code=502, detail=f"Unpairing failed. Output: {output}. Error: {error}")
+
+ return {"ok": True, "message": "Unpair command sent. Check output for details.", "output": output, "error": error}
+
+@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/cli.py b/fichero_printer/fichero/cli.py
similarity index 100%
rename from fichero/cli.py
rename to fichero_printer/fichero/cli.py
diff --git a/fichero/imaging.py b/fichero_printer/fichero/imaging.py
similarity index 100%
rename from fichero/imaging.py
rename to fichero_printer/fichero/imaging.py
diff --git a/fichero/index.html b/fichero_printer/fichero/index.html
similarity index 100%
rename from fichero/index.html
rename to fichero_printer/fichero/index.html
diff --git a/fichero/printer.py b/fichero_printer/fichero/printer.py
similarity index 100%
rename from fichero/printer.py
rename to fichero_printer/fichero/printer.py
diff --git a/pyproject.toml b/pyproject.toml
index c13c220..82f32ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "fichero-printer"
-version = "0.1.25"
+version = "0.1.27"
description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer."
readme = "README.md"
requires-python = ">=3.10"
@@ -27,5 +27,5 @@ fichero = "fichero.cli:main"
fichero-server = "fichero.api:main"
[tool.setuptools.packages.find]
-where = ["."]
+where = ["fichero_printer"]
include = ["fichero*"]
\ No newline at end of file
diff --git a/sync_addon.sh b/sync_addon.sh
new file mode 100644
index 0000000..e69de29