From 43495714e631c4332aa321f14080007a7e2e2aef Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 11:39:20 +0100 Subject: [PATCH 01/64] Implement HTTP API for Fichero D11s printer with status and print endpoints --- docs/PROTOCOL.md | 141 +++++++++++++++++++++++ fichero/api.py | 289 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 ++ 3 files changed, 438 insertions(+) create mode 100644 fichero/api.py diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index dad2b48..5cfc689 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -189,3 +189,144 @@ Fichero-branded printers: 4. Traced the device class hierarchy: D11s -> AiYinNormalDevice -> BaseNormalDevice 5. Found the AiYin-specific enable/stop commands that were different from the base class 6. Tested every discovered command against the actual hardware and documented which ones work + +--- + +## HTTP API (`fichero/api.py`) + +A FastAPI-based REST server that wraps the printer logic. + +### Installation + +```bash +pip install 'fichero-printer[api]' +``` + +### Starting the server + +```bash +fichero-server [--host HOST] [--port PORT] [--address BLE_ADDR] [--classic] [--channel N] +``` + +Default: `http://127.0.0.1:8765`. +The BLE address can also be set via the `FICHERO_ADDR` environment variable. +Interactive docs available at `http://127.0.0.1:8765/docs`. + +--- + +### `GET /status` + +Returns the real-time printer status. + +**Query parameters** (all optional): + +| Parameter | Type | Default | Description | +|-----------|---------|------------------|-------------------------------------| +| `address` | string | `FICHERO_ADDR` | BLE address (skips scanning) | +| `classic` | boolean | `false` | Use Classic Bluetooth RFCOMM | +| `channel` | integer | `1` | RFCOMM channel | + +**Response `200`:** +```json +{ + "ok": true, + "printing": false, + "cover_open": false, + "no_paper": false, + "low_battery": false, + "overheated": false, + "charging": false, + "raw": 0 +} +``` + +--- + +### `GET /info` + +Returns static and dynamic printer information (model, firmware, serial, battery, …). + +Same query parameters as `/status`. + +**Response `200`:** JSON object with all info keys returned by the printer. + +--- + +### `POST /print/text` + +Print a plain-text label. Sends `multipart/form-data`. + +**Form fields:** + +| Field | Type | Default | Required | Description | +|----------------|---------|---------|----------|--------------------------------------------------| +| `text` | string | — | ✓ | Text to print | +| `density` | integer | `2` | | Print density: 0=light, 1=medium, 2=dark | +| `paper` | string | `gap` | | Paper type: `gap`, `black`, `continuous` (0-2) | +| `copies` | integer | `1` | | Number of copies (1–99) | +| `font_size` | integer | `30` | | Font size in points | +| `label_length` | integer | — | | Label length in mm (overrides `label_height`) | +| `label_height` | integer | `240` | | Label height in pixels | +| `address` | string | — | | BLE address override | +| `classic` | boolean | — | | Use Classic Bluetooth RFCOMM | +| `channel` | integer | — | | RFCOMM channel | + +**Response `200`:** +```json +{ "ok": true, "copies": 1, "text": "Hello World" } +``` + +**Example (`curl`):** +```bash +curl -X POST http://127.0.0.1:8765/print/text \ + -F text="Hello World" \ + -F density=2 \ + -F paper=gap \ + -F label_length=30 +``` + +--- + +### `POST /print/image` + +Print an image file. Sends `multipart/form-data`. + +**Form fields:** + +| Field | Type | Default | Required | Description | +|----------------|---------|---------|----------|--------------------------------------------------| +| `file` | file | — | ✓ | Image file (PNG, JPEG, BMP, GIF, TIFF, WEBP) | +| `density` | integer | `2` | | Print density: 0=light, 1=medium, 2=dark | +| `paper` | string | `gap` | | Paper type: `gap`, `black`, `continuous` (0-2) | +| `copies` | integer | `1` | | Number of copies (1–99) | +| `dither` | boolean | `true` | | Apply Floyd-Steinberg dithering | +| `label_length` | integer | — | | Max label length in mm (overrides `label_height`)| +| `label_height` | integer | `240` | | Max label height in pixels | +| `address` | string | — | | BLE address override | +| `classic` | boolean | — | | Use Classic Bluetooth RFCOMM | +| `channel` | integer | — | | RFCOMM channel | + +**Response `200`:** +```json +{ "ok": true, "copies": 1, "filename": "label.png" } +``` + +**Example (`curl`):** +```bash +curl -X POST http://127.0.0.1:8765/print/image \ + -F file=@label.png \ + -F density=2 \ + -F dither=true \ + -F label_length=40 +``` + +--- + +### Error responses + +| Status | Meaning | +|--------|---------------------------------------------------------| +| `404` | Printer not found (BLE scan failed or address invalid) | +| `422` | Validation error (bad parameter value or empty file) | +| `502` | Printer communication error | +| `504` | Printer timed out | diff --git a/fichero/api.py b/fichero/api.py new file mode 100644 index 0000000..ac3ef28 --- /dev/null +++ b/fichero/api.py @@ -0,0 +1,289 @@ +"""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 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.0", + lifespan=lifespan, +) + +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 + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@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.""" + 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 + global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL + _DEFAULT_ADDRESS = args.address + _DEFAULT_CLASSIC = args.classic + _DEFAULT_CHANNEL = args.channel + + uvicorn.run( + "fichero.api:app", + host=args.host, + port=args.port, + reload=args.reload, + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index e012c53..8bee1ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,13 @@ dependencies = [ "pillow", ] +[project.optional-dependencies] +api = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + "python-multipart>=0.0.9", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -18,3 +25,4 @@ packages = ["fichero"] [project.scripts] fichero = "fichero.cli:main" +fichero-server = "fichero.api:main" From 14be205eb163f14d60aa89fdd6c46b5929fdc458 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 11:52:11 +0100 Subject: [PATCH 02/64] feat: Add Fichero D11s thermal label printer support with REST API and CLI - Implemented a new module for the Fichero D11s thermal label printer, including BLE and Classic Bluetooth interfaces. - Created a REST API using FastAPI to manage printer status, info, and printing tasks (text and images). - Developed a CLI for direct printer interaction, allowing users to print text and images, check status, and modify settings. - Added image processing capabilities for converting text and images to the required format for printing. - Introduced error handling for printer operations and connection management. - Included a shell script for running the API server with configurable parameters. - Added English translations for configuration options. - Created a repository metadata file for project management. --- fichero/api.py | 6 +- fichero_printer/DOCS.md | 144 ++++++++++ fichero_printer/Dockerfile | 30 +++ fichero_printer/build.yaml | 6 + fichero_printer/config.yaml | 42 +++ fichero_printer/fichero/__init__.py | 25 ++ fichero_printer/fichero/api.py | 293 +++++++++++++++++++++ fichero_printer/fichero/cli.py | 251 ++++++++++++++++++ fichero_printer/fichero/imaging.py | 97 +++++++ fichero_printer/fichero/printer.py | 380 +++++++++++++++++++++++++++ fichero_printer/run.sh | 29 ++ fichero_printer/translations/en.yaml | 13 + repository.yaml | 3 + 13 files changed, 1318 insertions(+), 1 deletion(-) create mode 100644 fichero_printer/DOCS.md create mode 100644 fichero_printer/Dockerfile create mode 100644 fichero_printer/build.yaml create mode 100644 fichero_printer/config.yaml create mode 100644 fichero_printer/fichero/__init__.py create mode 100644 fichero_printer/fichero/api.py create mode 100644 fichero_printer/fichero/cli.py create mode 100644 fichero_printer/fichero/imaging.py create mode 100644 fichero_printer/fichero/printer.py create mode 100644 fichero_printer/run.sh create mode 100644 fichero_printer/translations/en.yaml create mode 100644 repository.yaml diff --git a/fichero/api.py b/fichero/api.py index ac3ef28..bc0f9ce 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -277,8 +277,12 @@ def main() -> None: _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", + "fichero.api:app" if args.reload else app, host=args.host, port=args.port, reload=args.reload, diff --git a/fichero_printer/DOCS.md b/fichero_printer/DOCS.md new file mode 100644 index 0000000..887f4ed --- /dev/null +++ b/fichero_printer/DOCS.md @@ -0,0 +1,144 @@ +# Fichero Printer – Home Assistant Add-on + +Ein HTTP-REST-API-Server für den **Fichero D11s** (auch bekannt als AiYin D11s) +Thermodrucker. Das Add-on ermöglicht das Drucken von Textetiketten und Bildern +direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen. + +## Voraussetzungen + +- Fichero D11s / AiYin D11s Drucker +- Ein Bluetooth-Adapter, der vom Home Assistant OS erkannt wird +- Der Drucker muss eingeschaltet und in Reichweite sein + +## Konfiguration + +| Option | Standard | Beschreibung | +|---|---|---| +| `port` | `8765` | Port des REST-API-Servers (auch im „Port-Mapping" oben anpassen) | +| `ble_address` | _(leer)_ | Feste BLE-Adresse des Druckers (z.B. `AA:BB:CC:DD:EE:FF`). Leer lassen für automatischen Scan. | +| `transport` | `ble` | Verbindungsart: `ble` (Bluetooth Low Energy) oder `classic` (RFCOMM) | +| `channel` | `1` | RFCOMM-Kanal – nur relevant bei `transport: classic` | + +## Verwendung + +Nach dem Start ist die API unter `http://:` erreichbar. +Die interaktive Swagger-Dokumentation ist unter `http://:/docs` verfügbar. + +### Endpunkte + +#### `GET /status` + +Gibt den aktuellen Druckerstatus zurück. + +```bash +curl http://homeassistant.local:8765/status +``` + +Antwort: +```json +{ + "ok": true, + "printing": false, + "cover_open": false, + "no_paper": false, + "low_battery": false, + "overheated": false, + "charging": false, + "raw": 0 +} +``` + +#### `GET /info` + +Gibt Geräteinformationen zurück (Modell, Firmware, Seriennummer, Akkustand). + +```bash +curl http://homeassistant.local:8765/info +``` + +#### `POST /print/text` + +Druckt ein Textetikett. + +```bash +curl -X POST http://homeassistant.local:8765/print/text \ + -F text="Hallo Welt" \ + -F density=2 \ + -F paper=gap \ + -F label_length=30 +``` + +| Feld | Standard | Beschreibung | +|---|---|---| +| `text` | – | **Pflichtfeld.** Zu druckender Text | +| `density` | `2` | Druckdichte: `0`=hell, `1`=mittel, `2`=dunkel | +| `paper` | `gap` | Papierart: `gap`, `black`, `continuous` | +| `copies` | `1` | Anzahl der Kopien (1–99) | +| `font_size` | `30` | Schriftgröße in Punkt | +| `label_length` | – | Etikettenlänge in mm (überschreibt `label_height`) | +| `label_height` | `240` | Etikettenhöhe in Pixel | + +#### `POST /print/image` + +Druckt eine Bilddatei (PNG, JPEG, BMP, GIF, TIFF, WEBP). + +```bash +curl -X POST http://homeassistant.local:8765/print/image \ + -F file=@etikett.png \ + -F density=2 \ + -F dither=true \ + -F label_length=40 +``` + +| Feld | Standard | Beschreibung | +|---|---|---| +| `file` | – | **Pflichtfeld.** Bilddatei | +| `density` | `2` | Druckdichte: `0`=hell, `1`=mittel, `2`=dunkel | +| `paper` | `gap` | Papierart: `gap`, `black`, `continuous` | +| `copies` | `1` | Anzahl der Kopien (1–99) | +| `dither` | `true` | Floyd-Steinberg-Dithering aktivieren | +| `label_length` | – | Max. Etikettenlänge in mm | +| `label_height` | `240` | Max. Etikettenhöhe in Pixel | + +### Fehlercodes + +| Status | Bedeutung | +|---|---| +| `404` | Drucker nicht gefunden (BLE-Scan fehlgeschlagen oder Adresse ungültig) | +| `422` | Ungültige Parameter oder leere Datei | +| `502` | Kommunikationsfehler mit dem Drucker | +| `504` | Drucker hat nicht rechtzeitig geantwortet | + +## Home Assistant Automation – Beispiel + +```yaml +alias: Etikett drucken +trigger: + - platform: state + entity_id: input_text.etikett_text +action: + - service: rest_command.fichero_print_text + data: + text: "{{ states('input_text.etikett_text') }}" +``` + +In `configuration.yaml`: +```yaml +rest_command: + fichero_print_text: + url: "http://localhost:8765/print/text" + method: POST + content_type: "application/x-www-form-urlencoded" + payload: "text={{ text }}&density=2&label_length=30" +``` + +## Hinweise zur Bluetooth-Verbindung + +- **BLE (Standard):** Das Add-on benötigt Zugriff auf BlueZ über D-Bus + (`host_dbus: true`). Home Assistant OS stellt BlueZ automatisch bereit. +- **Classic Bluetooth (RFCOMM):** Nur unter Linux verfügbar. Erfordert die + direkte Bluetooth-Adresse (kein automatischer Scan möglich). +- Wenn die BLE-Adresse bekannt ist, diese in der Konfiguration eintragen – + das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig). +- Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird. + Es gibt keine persistente Verbindung – jede Anfrage verbindet sich neu. diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile new file mode 100644 index 0000000..7a02c3a --- /dev/null +++ b/fichero_printer/Dockerfile @@ -0,0 +1,30 @@ +ARG BUILD_FROM +FROM $BUILD_FROM + +# Install Bluetooth system libraries (BlueZ for BLE/RFCOMM) +RUN apk add --no-cache \ + bluez \ + bluez-deprecated \ + dbus + +# Install Python runtime dependencies +RUN pip3 install --no-cache-dir --break-system-packages \ + "bleak>=0.21" \ + "numpy" \ + "pillow" \ + "fastapi>=0.111" \ + "uvicorn[standard]>=0.29" \ + "python-multipart>=0.0.9" + +# Copy the fichero Python package into the container +WORKDIR /app +COPY fichero/ /app/fichero/ + +# Make the package importable without installation +ENV PYTHONPATH=/app + +# Copy and register the startup script +COPY run.sh /usr/bin/run.sh +RUN chmod +x /usr/bin/run.sh + +CMD ["/usr/bin/run.sh"] diff --git a/fichero_printer/build.yaml b/fichero_printer/build.yaml new file mode 100644 index 0000000..c367d63 --- /dev/null +++ b/fichero_printer/build.yaml @@ -0,0 +1,6 @@ +build_from: + aarch64: "ghcr.io/home-assistant/aarch64-base-python:3.13" + amd64: "ghcr.io/home-assistant/amd64-base-python:3.13" + armhf: "ghcr.io/home-assistant/armhf-base-python:3.13" + armv7: "ghcr.io/home-assistant/armv7-base-python:3.13" + i386: "ghcr.io/home-assistant/i386-base-python:3.13" diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml new file mode 100644 index 0000000..5aeab8a --- /dev/null +++ b/fichero_printer/config.yaml @@ -0,0 +1,42 @@ +name: "Fichero Printer" +version: "0.1.0" +slug: "fichero_printer" +description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth" +url: "https://git.leuschner.dev/Tobias/Fichero" + +arch: + - aarch64 + - amd64 + - armhf + - armv7 + - i386 + +init: false +startup: application +boot: auto + +host_network: true +host_dbus: true + +# NET_ADMIN is required for Classic Bluetooth (RFCOMM) raw socket access. +# BLE uses D-Bus (host_dbus) and does not need this. +privileged: + - NET_ADMIN + +options: + port: 8765 + ble_address: "" + transport: "ble" + channel: 1 + +schema: + port: int + ble_address: str? + transport: list(ble|classic) + channel: int + +ports: + 8765/tcp: 8765 + +ports_description: + 8765/tcp: "Fichero Printer REST API" diff --git a/fichero_printer/fichero/__init__.py b/fichero_printer/fichero/__init__.py new file mode 100644 index 0000000..559f7b5 --- /dev/null +++ b/fichero_printer/fichero/__init__.py @@ -0,0 +1,25 @@ +"""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 new file mode 100644 index 0000000..bc0f9ce --- /dev/null +++ b/fichero_printer/fichero/api.py @@ -0,0 +1,293 @@ +"""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 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.0", + lifespan=lifespan, +) + +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 + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@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.""" + 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 + global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL + _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 new file mode 100644 index 0000000..978e694 --- /dev/null +++ b/fichero_printer/fichero/cli.py @@ -0,0 +1,251 @@ +"""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 new file mode 100644 index 0000000..5d1554b --- /dev/null +++ b/fichero_printer/fichero/imaging.py @@ -0,0 +1,97 @@ +"""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 new file mode 100644 index 0000000..cd42067 --- /dev/null +++ b/fichero_printer/fichero/printer.py @@ -0,0 +1,380 @@ +""" +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 + +# --- 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 + + +# --- 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?") + + +# --- 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 + ) + sock.setblocking(False) + loop = asyncio.get_running_loop() + try: + await asyncio.wait_for( + loop.sock_connect(sock, (self._address, self._channel)), + timeout=10.0, + ) + 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)") + async with RFCOMMClient(address, channel) as client: + pc = PrinterClient(client) + await pc.start() + yield pc + else: + addr = address or await find_printer() + async with BleakClient(addr) as client: + pc = PrinterClient(client) + await pc.start() + yield pc diff --git a/fichero_printer/run.sh b/fichero_printer/run.sh new file mode 100644 index 0000000..d8af223 --- /dev/null +++ b/fichero_printer/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/with-contenv bashio +# shellcheck shell=bash +set -e + +declare port +declare ble_address +declare transport +declare channel + +port=$(bashio::config 'port') +transport=$(bashio::config 'transport') +channel=$(bashio::config 'channel') + +# Pass connection settings to the Python module via environment variables. +# The module reads these at import time, so they must be exported before uvicorn +# imports fichero.api. +export FICHERO_TRANSPORT="${transport}" +export FICHERO_CHANNEL="${channel}" + +ble_address=$(bashio::config 'ble_address') +if [ -n "${ble_address}" ]; then + export FICHERO_ADDR="${ble_address}" + bashio::log.info "Using fixed Bluetooth address: ${ble_address}" +else + bashio::log.info "No address configured – will auto-scan for printer on first request." +fi + +bashio::log.info "Starting Fichero Printer API on 0.0.0.0:${port} (transport: ${transport})..." +exec uvicorn fichero.api:app --host 0.0.0.0 --port "${port}" diff --git a/fichero_printer/translations/en.yaml b/fichero_printer/translations/en.yaml new file mode 100644 index 0000000..0f8a2f6 --- /dev/null +++ b/fichero_printer/translations/en.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." diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..3355d47 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: "Fichero Printer" +url: "https://git.leuschner.dev/Tobias/Fichero" +maintainer: "Tobias Leuschner" From f94f6a686d0e1425630fabd986bb7edcf06dca5f Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 11:59:31 +0100 Subject: [PATCH 03/64] fix: Update Python base images to version 3.12 in build configuration refactor: Improve Dockerfile by adding necessary build tools and cleaning up --- fichero_printer/Dockerfile | 17 ++++++++++++----- fichero_printer/build.yaml | 10 +++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index 7a02c3a..d897c0c 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -1,11 +1,15 @@ ARG BUILD_FROM FROM $BUILD_FROM -# Install Bluetooth system libraries (BlueZ for BLE/RFCOMM) +# System libraries: BlueZ for BLE/RFCOMM + build tools for numpy & pillow RUN apk add --no-cache \ bluez \ - bluez-deprecated \ - dbus + dbus \ + gcc \ + musl-dev \ + libjpeg-turbo-dev \ + zlib-dev \ + libffi-dev # Install Python runtime dependencies RUN pip3 install --no-cache-dir --break-system-packages \ @@ -16,6 +20,9 @@ RUN pip3 install --no-cache-dir --break-system-packages \ "uvicorn[standard]>=0.29" \ "python-multipart>=0.0.9" +# Remove build-only packages to keep the image slim +RUN apk del gcc musl-dev libffi-dev + # Copy the fichero Python package into the container WORKDIR /app COPY fichero/ /app/fichero/ @@ -23,8 +30,8 @@ COPY fichero/ /app/fichero/ # Make the package importable without installation ENV PYTHONPATH=/app -# Copy and register the startup script +# Copy startup script and normalise line endings (Windows CRLF -> LF) COPY run.sh /usr/bin/run.sh -RUN chmod +x /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/build.yaml b/fichero_printer/build.yaml index c367d63..8235ba3 100644 --- a/fichero_printer/build.yaml +++ b/fichero_printer/build.yaml @@ -1,6 +1,6 @@ build_from: - aarch64: "ghcr.io/home-assistant/aarch64-base-python:3.13" - amd64: "ghcr.io/home-assistant/amd64-base-python:3.13" - armhf: "ghcr.io/home-assistant/armhf-base-python:3.13" - armv7: "ghcr.io/home-assistant/armv7-base-python:3.13" - i386: "ghcr.io/home-assistant/i386-base-python:3.13" + aarch64: "ghcr.io/home-assistant/aarch64-base-python:3.12" + amd64: "ghcr.io/home-assistant/amd64-base-python:3.12" + armhf: "ghcr.io/home-assistant/armhf-base-python:3.12" + armv7: "ghcr.io/home-assistant/armv7-base-python:3.12" + i386: "ghcr.io/home-assistant/i386-base-python:3.12" From 218b3c4961121fdaaa478b5d2bd46304e0a6f7c9 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 12:08:34 +0100 Subject: [PATCH 04/64] fix: use python:3.12-alpine3.21 from Docker Hub, rewrite run.sh without bashio --- fichero_printer/Dockerfile | 10 +++++----- fichero_printer/build.yaml | 10 +++++----- fichero_printer/run.sh | 37 ++++++++++++++++--------------------- 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index d897c0c..41a8c8e 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -1,18 +1,18 @@ ARG BUILD_FROM FROM $BUILD_FROM -# System libraries: BlueZ for BLE/RFCOMM + build tools for numpy & pillow +# BlueZ for BLE/RFCOMM + build tools for packages without binary wheels RUN apk add --no-cache \ + bash \ bluez \ dbus \ gcc \ musl-dev \ libjpeg-turbo-dev \ - zlib-dev \ - libffi-dev + zlib-dev # Install Python runtime dependencies -RUN pip3 install --no-cache-dir --break-system-packages \ +RUN pip install --no-cache-dir \ "bleak>=0.21" \ "numpy" \ "pillow" \ @@ -21,7 +21,7 @@ RUN pip3 install --no-cache-dir --break-system-packages \ "python-multipart>=0.0.9" # Remove build-only packages to keep the image slim -RUN apk del gcc musl-dev libffi-dev +RUN apk del gcc musl-dev # Copy the fichero Python package into the container WORKDIR /app diff --git a/fichero_printer/build.yaml b/fichero_printer/build.yaml index 8235ba3..305711b 100644 --- a/fichero_printer/build.yaml +++ b/fichero_printer/build.yaml @@ -1,6 +1,6 @@ build_from: - aarch64: "ghcr.io/home-assistant/aarch64-base-python:3.12" - amd64: "ghcr.io/home-assistant/amd64-base-python:3.12" - armhf: "ghcr.io/home-assistant/armhf-base-python:3.12" - armv7: "ghcr.io/home-assistant/armv7-base-python:3.12" - i386: "ghcr.io/home-assistant/i386-base-python:3.12" + aarch64: "python:3.12-alpine3.21" + amd64: "python:3.12-alpine3.21" + armhf: "python:3.12-alpine3.21" + armv7: "python:3.12-alpine3.21" + i386: "python:3.12-alpine3.21" diff --git a/fichero_printer/run.sh b/fichero_printer/run.sh index d8af223..08bde2f 100644 --- a/fichero_printer/run.sh +++ b/fichero_printer/run.sh @@ -1,29 +1,24 @@ -#!/usr/bin/with-contenv bashio -# shellcheck shell=bash +#!/bin/sh +# shellcheck shell=sh set -e -declare port -declare ble_address -declare transport -declare channel +CONFIG_PATH="/data/options.json" -port=$(bashio::config 'port') -transport=$(bashio::config 'transport') -channel=$(bashio::config 'channel') +# Read add-on options from the HA-provided JSON file using Python (already installed). +PORT=$(python3 -c "import json; d=json.load(open('${CONFIG_PATH}')); print(d.get('port', 8765))") +TRANSPORT=$(python3 -c "import json; d=json.load(open('${CONFIG_PATH}')); print(d.get('transport', 'ble'))") +CHANNEL=$(python3 -c "import json; d=json.load(open('${CONFIG_PATH}')); print(d.get('channel', 1))") +BLE_ADDRESS=$(python3 -c "import json; d=json.load(open('${CONFIG_PATH}')); print(d.get('ble_address') or '')") -# Pass connection settings to the Python module via environment variables. -# The module reads these at import time, so they must be exported before uvicorn -# imports fichero.api. -export FICHERO_TRANSPORT="${transport}" -export FICHERO_CHANNEL="${channel}" +export FICHERO_TRANSPORT="${TRANSPORT}" +export FICHERO_CHANNEL="${CHANNEL}" -ble_address=$(bashio::config 'ble_address') -if [ -n "${ble_address}" ]; then - export FICHERO_ADDR="${ble_address}" - bashio::log.info "Using fixed Bluetooth address: ${ble_address}" +if [ -n "${BLE_ADDRESS}" ]; then + export FICHERO_ADDR="${BLE_ADDRESS}" + echo "[fichero] Using fixed Bluetooth address: ${BLE_ADDRESS}" else - bashio::log.info "No address configured – will auto-scan for printer on first request." + echo "[fichero] No address configured - will auto-scan for printer on first request." fi -bashio::log.info "Starting Fichero Printer API on 0.0.0.0:${port} (transport: ${transport})..." -exec uvicorn fichero.api:app --host 0.0.0.0 --port "${port}" +echo "[fichero] Starting Fichero Printer API on 0.0.0.0:${PORT} (transport: ${TRANSPORT})..." +exec uvicorn fichero.api:app --host 0.0.0.0 --port "${PORT}" From e723b07fcd1c74076100348c5f81ad61fe7089f2 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 12:13:00 +0100 Subject: [PATCH 05/64] fix: use amd64-base:latest with py3-numpy/py3-pillow from apk, no compiler needed --- fichero_printer/Dockerfile | 21 ++++++++------------- fichero_printer/build.yaml | 10 +++++----- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index 41a8c8e..1c243d3 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -1,28 +1,23 @@ ARG BUILD_FROM FROM $BUILD_FROM -# BlueZ for BLE/RFCOMM + build tools for packages without binary wheels +# Python 3, BlueZ and pre-compiled Alpine packages (no C compiler needed) RUN apk add --no-cache \ bash \ + python3 \ + py3-pip \ + py3-numpy \ + py3-pillow \ bluez \ - dbus \ - gcc \ - musl-dev \ - libjpeg-turbo-dev \ - zlib-dev + dbus -# Install Python runtime dependencies -RUN pip install --no-cache-dir \ +# Pure-Python packages only (no compilation required) +RUN pip3 install --no-cache-dir --break-system-packages \ "bleak>=0.21" \ - "numpy" \ - "pillow" \ "fastapi>=0.111" \ "uvicorn[standard]>=0.29" \ "python-multipart>=0.0.9" -# Remove build-only packages to keep the image slim -RUN apk del gcc musl-dev - # Copy the fichero Python package into the container WORKDIR /app COPY fichero/ /app/fichero/ diff --git a/fichero_printer/build.yaml b/fichero_printer/build.yaml index 305711b..48722ec 100644 --- a/fichero_printer/build.yaml +++ b/fichero_printer/build.yaml @@ -1,6 +1,6 @@ build_from: - aarch64: "python:3.12-alpine3.21" - amd64: "python:3.12-alpine3.21" - armhf: "python:3.12-alpine3.21" - armv7: "python:3.12-alpine3.21" - i386: "python:3.12-alpine3.21" + aarch64: "ghcr.io/home-assistant/aarch64-base:latest" + amd64: "ghcr.io/home-assistant/amd64-base:latest" + armhf: "ghcr.io/home-assistant/armhf-base:latest" + armv7: "ghcr.io/home-assistant/armv7-base:latest" + i386: "ghcr.io/home-assistant/i386-base:latest" From c2d5baa34d74a203527f8623b75c356216373fa2 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 12:18:32 +0100 Subject: [PATCH 06/64] fix: move global declaration to top of main() in api.py --- fichero/api.py | 3 ++- fichero_printer/fichero/api.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fichero/api.py b/fichero/api.py index bc0f9ce..f916e2b 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -252,6 +252,8 @@ async def print_image( def main() -> None: """Start the Fichero HTTP API server.""" + global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL + try: import uvicorn # noqa: PLC0415 except ImportError: @@ -272,7 +274,6 @@ def main() -> None: args = parser.parse_args() # Push CLI overrides into module-level defaults so all handlers pick them up - global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL _DEFAULT_ADDRESS = args.address _DEFAULT_CLASSIC = args.classic _DEFAULT_CHANNEL = args.channel diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index bc0f9ce..f916e2b 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -252,6 +252,8 @@ async def print_image( def main() -> None: """Start the Fichero HTTP API server.""" + global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL + try: import uvicorn # noqa: PLC0415 except ImportError: @@ -272,7 +274,6 @@ def main() -> None: args = parser.parse_args() # Push CLI overrides into module-level defaults so all handlers pick them up - global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL _DEFAULT_ADDRESS = args.address _DEFAULT_CLASSIC = args.classic _DEFAULT_CHANNEL = args.channel From 4567986e69cef3accd5f674fc09682659f61960e Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 12:19:55 +0100 Subject: [PATCH 07/64] chore: bump version to 0.1.1 to trigger HA rebuild --- fichero_printer/config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 5aeab8a..a7854c7 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.0" +version: "0.1.1" 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/pyproject.toml b/pyproject.toml index 8bee1ce..0b25c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.0" +version = "0.1.1" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 7ab3f8f4d2e1a4a309863ea5651f34c4f46e04f1 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 12:25:47 +0100 Subject: [PATCH 08/64] fix: remove container bluez, use host BlueZ via D-Bus, set DBUS_SYSTEM_BUS_ADDRESS --- fichero_printer/Dockerfile | 8 ++++---- fichero_printer/config.yaml | 2 +- fichero_printer/run.sh | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index 1c243d3..9b9a31a 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -1,17 +1,17 @@ ARG BUILD_FROM FROM $BUILD_FROM -# Python 3, BlueZ and pre-compiled Alpine packages (no C compiler needed) +# Only dbus-dev needed to talk to the HOST BlueZ via D-Bus (host_dbus: true). +# 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 \ - bluez \ - dbus + dbus-dev -# Pure-Python packages only (no compilation required) +# 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" \ diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index a7854c7..696a7e5 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.1" +version: "0.1.2" 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/run.sh b/fichero_printer/run.sh index 08bde2f..f3ecb24 100644 --- a/fichero_printer/run.sh +++ b/fichero_printer/run.sh @@ -4,6 +4,9 @@ set -e CONFIG_PATH="/data/options.json" +# Use the host BlueZ via D-Bus (requires host_dbus: true in config.yaml) +export DBUS_SYSTEM_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" + # Read add-on options from the HA-provided JSON file using Python (already installed). PORT=$(python3 -c "import json; d=json.load(open('${CONFIG_PATH}')); print(d.get('port', 8765))") TRANSPORT=$(python3 -c "import json; d=json.load(open('${CONFIG_PATH}')); print(d.get('transport', 'ble'))") From 09f340c6e980ab1b4ead8ddffd919d45b6df2114 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 13:39:00 +0100 Subject: [PATCH 09/64] add features --- README.md | 5 +++++ fichero/api.py | 6 ++++++ fichero/printer.py | 19 +++++++++++++++---- fichero_printer/DOCS.md | 13 +++++++++++-- fichero_printer/config.yaml | 5 +++++ fichero_printer/fichero/api.py | 6 ++++++ fichero_printer/fichero/printer.py | 19 +++++++++++++++---- 7 files changed, 63 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b818e77..af68d20 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer. +## Credits + +- Original developer/project: [0xMH/fichero-printer](https://github.com/0xMH/fichero-printer) +- This repository version was additionally extended with AI-assisted changes. + Blog post: [Reverse Engineering Action's Cheap Fichero Labelprinter](https://blog.dbuglife.com/reverse-engineering-fichero-label-printer/) The [Fichero](https://www.action.com/nl-nl/p/3212141/fichero-labelprinter/) is a cheap Bluetooth thermal label printer sold at Action. Internally it's an AiYin D11s made by Xiamen Print Future Technology. The official app is closed-source and doesn't expose the protocol, so this project reverse-engineers it from the decompiled APK. diff --git a/fichero/api.py b/fichero/api.py index f916e2b..9af0940 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -23,6 +23,7 @@ from typing import Annotated from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from PIL import Image from fichero.cli import DOTS_PER_MM, do_print @@ -91,6 +92,11 @@ def _address(address: str | None) -> str | None: # Endpoints # --------------------------------------------------------------------------- +@app.get("/", include_in_schema=False) +async def root(): + """Redirect root to interactive API docs.""" + return RedirectResponse(url="/docs") + @app.get( "/status", diff --git a/fichero/printer.py b/fichero/printer.py index cd42067..446d7ad 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -12,6 +12,7 @@ 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+) --- @@ -374,7 +375,17 @@ async def connect( yield pc else: addr = address or await find_printer() - async with BleakClient(addr) as client: - pc = PrinterClient(client) - await pc.start() - yield pc + try: + async with BleakClient(addr) as client: + pc = PrinterClient(client) + await pc.start() + yield pc + except BleakDBusError as exc: + if "br-connection-not-supported" in str(exc).lower(): + raise PrinterError( + "BLE connection failed (br-connection-not-supported). " + "Try Classic Bluetooth with classic=true and channel=1." + ) from exc + raise PrinterError(f"BLE connection failed: {exc}") from exc + except BleakError as exc: + raise PrinterError(f"BLE error: {exc}") from exc diff --git a/fichero_printer/DOCS.md b/fichero_printer/DOCS.md index 887f4ed..5f25e6b 100644 --- a/fichero_printer/DOCS.md +++ b/fichero_printer/DOCS.md @@ -4,6 +4,11 @@ Ein HTTP-REST-API-Server für den **Fichero D11s** (auch bekannt als AiYin D11s) Thermodrucker. Das Add-on ermöglicht das Drucken von Textetiketten und Bildern direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen. +## Herkunft / Credits + +- Originalentwickler / Ursprungsprojekt: https://github.com/0xMH/fichero-printer +- Diese Variante wurde zusätzlich mit AI-unterstützten Erweiterungen ergänzt. + ## Voraussetzungen - Fichero D11s / AiYin D11s Drucker @@ -21,8 +26,12 @@ direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen. ## Verwendung -Nach dem Start ist die API unter `http://:` erreichbar. -Die interaktive Swagger-Dokumentation ist unter `http://:/docs` verfügbar. +Das Add-on ist nach dem Start auf zwei Arten erreichbar: + +1. Home Assistant UI (Ingress): In der Add-on-Seite auf **"Öffnen"** klicken. +2. Direkt per Port im Netzwerk: `http://:` (z.B. `http://homeassistant.local:8765`). + +Hinweis: `/` leitet auf `/docs` weiter (Swagger UI). ### Endpunkte diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 696a7e5..f83f967 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -14,6 +14,11 @@ arch: init: false startup: application boot: auto +ingress: true +ingress_port: 8765 +panel_icon: mdi:printer +panel_title: Fichero Printer +webui: "http://[HOST]:[PORT:8765]/docs" host_network: true host_dbus: true diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index f916e2b..9af0940 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -23,6 +23,7 @@ from typing import Annotated from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from PIL import Image from fichero.cli import DOTS_PER_MM, do_print @@ -91,6 +92,11 @@ def _address(address: str | None) -> str | None: # Endpoints # --------------------------------------------------------------------------- +@app.get("/", include_in_schema=False) +async def root(): + """Redirect root to interactive API docs.""" + return RedirectResponse(url="/docs") + @app.get( "/status", diff --git a/fichero_printer/fichero/printer.py b/fichero_printer/fichero/printer.py index cd42067..446d7ad 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -12,6 +12,7 @@ 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+) --- @@ -374,7 +375,17 @@ async def connect( yield pc else: addr = address or await find_printer() - async with BleakClient(addr) as client: - pc = PrinterClient(client) - await pc.start() - yield pc + try: + async with BleakClient(addr) as client: + pc = PrinterClient(client) + await pc.start() + yield pc + except BleakDBusError as exc: + if "br-connection-not-supported" in str(exc).lower(): + raise PrinterError( + "BLE connection failed (br-connection-not-supported). " + "Try Classic Bluetooth with classic=true and channel=1." + ) from exc + raise PrinterError(f"BLE connection failed: {exc}") from exc + except BleakError as exc: + raise PrinterError(f"BLE error: {exc}") from exc From 99c2fb79d7c4d2500f9c489bf31f02ff928c70a3 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 13:50:51 +0100 Subject: [PATCH 10/64] fix error 500 --- fichero/api.py | 2 +- fichero/printer.py | 10 ++++++++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 2 +- fichero_printer/fichero/printer.py | 10 ++++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/fichero/api.py b/fichero/api.py index 9af0940..d191d8a 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -95,7 +95,7 @@ def _address(address: str | None) -> str | None: @app.get("/", include_in_schema=False) async def root(): """Redirect root to interactive API docs.""" - return RedirectResponse(url="/docs") + return RedirectResponse(url="docs") @app.get( diff --git a/fichero/printer.py b/fichero/printer.py index 446d7ad..1fbf137 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -162,6 +162,16 @@ class RFCOMMClient: loop.sock_connect(sock, (self._address, self._channel)), timeout=10.0, ) + 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 diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index f83f967..6d22711 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -18,7 +18,7 @@ ingress: true ingress_port: 8765 panel_icon: mdi:printer panel_title: Fichero Printer -webui: "http://[HOST]:[PORT:8765]/docs" +webui: "http://[HOST]:[PORT:8765]/" host_network: true host_dbus: true diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index 9af0940..d191d8a 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -95,7 +95,7 @@ def _address(address: str | None) -> str | None: @app.get("/", include_in_schema=False) async def root(): """Redirect root to interactive API docs.""" - return RedirectResponse(url="/docs") + return RedirectResponse(url="docs") @app.get( diff --git a/fichero_printer/fichero/printer.py b/fichero_printer/fichero/printer.py index 446d7ad..1fbf137 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -162,6 +162,16 @@ class RFCOMMClient: loop.sock_connect(sock, (self._address, self._channel)), timeout=10.0, ) + 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 From 33569099821ed669b507a98a437f50e231927d45 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 13:54:04 +0100 Subject: [PATCH 11/64] Bump add-on and package version to 0.1.3 --- fichero_printer/config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 6d22711..242a9e0 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.2" +version: "0.1.3" 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/pyproject.toml b/pyproject.toml index 0b25c3d..af58e2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.1" +version = "0.1.3" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From aa125736f3310f99710e8b0da9b5d840e7a61d09 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 13:59:30 +0100 Subject: [PATCH 12/64] Fix RFCOMM connect under uvloop and bump version to 0.1.4 --- fichero/printer.py | 13 +++++++++---- fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 13 +++++++++---- pyproject.toml | 2 +- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/fichero/printer.py b/fichero/printer.py index 1fbf137..6a9da82 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -155,13 +155,18 @@ class RFCOMMClient: sock = _socket.socket( _socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM ) - sock.setblocking(False) loop = asyncio.get_running_loop() try: - await asyncio.wait_for( - loop.sock_connect(sock, (self._address, self._channel)), - timeout=10.0, + # 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( diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 242a9e0..1f397bf 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.3" +version: "0.1.4" 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/printer.py b/fichero_printer/fichero/printer.py index 1fbf137..6a9da82 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -155,13 +155,18 @@ class RFCOMMClient: sock = _socket.socket( _socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM ) - sock.setblocking(False) loop = asyncio.get_running_loop() try: - await asyncio.wait_for( - loop.sock_connect(sock, (self._address, self._channel)), - timeout=10.0, + # 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( diff --git a/pyproject.toml b/pyproject.toml index af58e2e..ac6e3de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.3" +version = "0.1.4" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 8c00001d68fe9966a83bf305f74f26d5a68acaa9 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 14:04:50 +0100 Subject: [PATCH 13/64] Add NET_RAW for RFCOMM and bump add-on version to 0.1.5 --- fichero_printer/DOCS.md | 3 ++- fichero_printer/config.yaml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fichero_printer/DOCS.md b/fichero_printer/DOCS.md index 5f25e6b..c0718d2 100644 --- a/fichero_printer/DOCS.md +++ b/fichero_printer/DOCS.md @@ -146,7 +146,8 @@ rest_command: - **BLE (Standard):** Das Add-on benötigt Zugriff auf BlueZ über D-Bus (`host_dbus: true`). Home Assistant OS stellt BlueZ automatisch bereit. - **Classic Bluetooth (RFCOMM):** Nur unter Linux verfügbar. Erfordert die - direkte Bluetooth-Adresse (kein automatischer Scan möglich). + direkte Bluetooth-Adresse (kein automatischer Scan möglich) und Container- + Rechte für Bluetooth-Sockets (`NET_ADMIN` + `NET_RAW`). - Wenn die BLE-Adresse bekannt ist, diese in der Konfiguration eintragen – das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig). - Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 1f397bf..e6f58c0 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.4" +version: "0.1.5" slug: "fichero_printer" description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth" url: "https://git.leuschner.dev/Tobias/Fichero" @@ -27,6 +27,7 @@ host_dbus: true # BLE uses D-Bus (host_dbus) and does not need this. privileged: - NET_ADMIN + - NET_RAW options: port: 8765 From 081883c823ec37662ffb9e1f1bae8749d8ff3e52 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 14:13:44 +0100 Subject: [PATCH 14/64] Add HA print web UI and release version 0.1.8 --- CHANGELOG.md | 45 +++++ README.md | 5 + fichero/api.py | 320 ++++++++++++++++++++++++++++++++- fichero_printer/DOCS.md | 6 +- fichero_printer/config.yaml | 3 +- fichero_printer/fichero/api.py | 320 ++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 7 files changed, 689 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5500feb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +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.8] - 2026-03-07 + +### Added +- Root URL now serves a built-in printer web interface for Home Assistant with status, info, text printing, and image upload printing. + +### Changed +- Swagger docs remain available under `/docs` while the Home Assistant "Open" action now lands on the print UI. + +## [0.1.7] - 2026-03-07 + +### Fixed +- Home Assistant ingress docs now use a custom Swagger UI route with a relative `openapi.json` URL, avoiding `404 /openapi.json` behind ingress prefixes. + +### Changed +- Home Assistant add-on now requests `full_access: true` in addition to Bluetooth capabilities to unblock Classic RFCOMM socket access on stricter hosts. + +## [0.1.6] - 2026-03-07 + +### Added +- Added this `CHANGELOG.md` and established a release policy to update version and changelog for every change. + +## [0.1.5] - 2026-03-07 + +### Changed +- Home Assistant add-on now requests `NET_RAW` in addition to `NET_ADMIN` for Classic Bluetooth RFCOMM sockets. +- Add-on documentation updated with Classic permission requirements. + +## [0.1.4] - 2026-03-07 + +### Fixed +- RFCOMM connection under `uvloop` now uses direct Bluetooth socket connect in a worker thread, avoiding address-family resolution issues. +- Classic Bluetooth socket errors are mapped to API-safe printer errors instead of unhandled 500s. + +## [0.1.3] - 2026-03-07 + +### Changed +- Home Assistant add-on metadata updated for ingress/web UI access. +- API root endpoint now redirects to docs in an ingress-compatible way. +- Added attribution for original upstream project and AI-assisted extension note. diff --git a/README.md b/README.md index af68d20..f8ff60e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,11 @@ Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal lab - Original developer/project: [0xMH/fichero-printer](https://github.com/0xMH/fichero-printer) - This repository version was additionally extended with AI-assisted changes. +## Release Policy + +- Maintain `CHANGELOG.md` for every user-visible change. +- Bump the project/add-on version with every merged change. + Blog post: [Reverse Engineering Action's Cheap Fichero Labelprinter](https://blog.dbuglife.com/reverse-engineering-fichero-label-printer/) The [Fichero](https://www.action.com/nl-nl/p/3212141/fichero-labelprinter/) is a cheap Bluetooth thermal label printer sold at Action. Internally it's an AiYin D11s made by Xiamen Print Future Technology. The official app is closed-source and doesn't expose the protocol, so this project reverse-engineers it from the decompiled APK. diff --git a/fichero/api.py b/fichero/api.py index d191d8a..a96eb7b 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -23,7 +23,8 @@ from typing import Annotated from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse +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 @@ -73,6 +74,8 @@ app = FastAPI( description="REST API for the Fichero D11s (AiYin) thermal label printer.", version="0.1.0", lifespan=lifespan, + docs_url=None, + redoc_url=None, ) app.add_middleware( @@ -88,14 +91,323 @@ def _address(address: str | None) -> str | None: 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 + + + +
+
+

Fichero Printer

+

Home Assistant print console for status, text labels, and image uploads.

+

API docs remain available at /docs.

+
+ +
+
+

Connection

+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+

Output

+
Ready.
+
+ +
+

Print Text

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Print Image

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + +""" + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- -@app.get("/", include_in_schema=False) +@app.get("/", include_in_schema=False, response_class=HTMLResponse) async def root(): - """Redirect root to interactive API docs.""" - return RedirectResponse(url="docs") + """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( diff --git a/fichero_printer/DOCS.md b/fichero_printer/DOCS.md index c0718d2..82e51b5 100644 --- a/fichero_printer/DOCS.md +++ b/fichero_printer/DOCS.md @@ -28,10 +28,10 @@ direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen. Das Add-on ist nach dem Start auf zwei Arten erreichbar: -1. Home Assistant UI (Ingress): In der Add-on-Seite auf **"Öffnen"** klicken. +1. Home Assistant UI (Ingress): In der Add-on-Seite auf **"Öffnen"** klicken. Dort erscheint direkt das Webinterface zum Abrufen von Status/Info sowie zum Drucken von Text und Bildern. 2. Direkt per Port im Netzwerk: `http://:` (z.B. `http://homeassistant.local:8765`). -Hinweis: `/` leitet auf `/docs` weiter (Swagger UI). +Hinweis: Die API-Dokumentation bleibt unter `/docs` erreichbar. ### Endpunkte @@ -148,6 +148,8 @@ rest_command: - **Classic Bluetooth (RFCOMM):** Nur unter Linux verfügbar. Erfordert die direkte Bluetooth-Adresse (kein automatischer Scan möglich) und Container- Rechte für Bluetooth-Sockets (`NET_ADMIN` + `NET_RAW`). +- Das Add-on läuft dafür mit `full_access`, weil einige Home-Assistant-Hosts + RFCOMM trotz gesetzter Capabilities sonst weiterhin blockieren. - Wenn die BLE-Adresse bekannt ist, diese in der Konfiguration eintragen – das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig). - Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index e6f58c0..4edbadc 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.5" +version: "0.1.8" slug: "fichero_printer" description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth" url: "https://git.leuschner.dev/Tobias/Fichero" @@ -19,6 +19,7 @@ ingress_port: 8765 panel_icon: mdi:printer panel_title: Fichero Printer webui: "http://[HOST]:[PORT:8765]/" +full_access: true host_network: true host_dbus: true diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index d191d8a..a96eb7b 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -23,7 +23,8 @@ from typing import Annotated from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse +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 @@ -73,6 +74,8 @@ app = FastAPI( description="REST API for the Fichero D11s (AiYin) thermal label printer.", version="0.1.0", lifespan=lifespan, + docs_url=None, + redoc_url=None, ) app.add_middleware( @@ -88,14 +91,323 @@ def _address(address: str | None) -> str | None: 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 + + + +
+
+

Fichero Printer

+

Home Assistant print console for status, text labels, and image uploads.

+

API docs remain available at /docs.

+
+ +
+
+

Connection

+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+

Output

+
Ready.
+
+ +
+

Print Text

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Print Image

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + +""" + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- -@app.get("/", include_in_schema=False) +@app.get("/", include_in_schema=False, response_class=HTMLResponse) async def root(): - """Redirect root to interactive API docs.""" - return RedirectResponse(url="docs") + """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( diff --git a/pyproject.toml b/pyproject.toml index ac6e3de..b881a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.4" +version = "0.1.8" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 54ba6795c03b4e5346b7f70565bd763b574e7690 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 14:21:36 +0100 Subject: [PATCH 15/64] Add add-on changelog and improve classic RFCOMM fallback (0.1.9) --- CHANGELOG.md | 8 ++++++++ fichero/printer.py | 23 +++++++++++++++++---- fichero_printer/CHANGELOG.md | 32 ++++++++++++++++++++++++++++++ fichero_printer/DOCS.md | 2 +- fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 23 +++++++++++++++++---- pyproject.toml | 2 +- 7 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 fichero_printer/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5500feb..b404860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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.9] - 2026-03-07 + +### Added +- Added add-on-local changelog at `fichero_printer/CHANGELOG.md` so Home Assistant can display release notes in the add-on UI. + +### Changed +- Improved Classic Bluetooth connect logic by trying fallback RFCOMM channels (1-3 plus configured channel) before failing. + ## [0.1.8] - 2026-03-07 ### Added diff --git a/fichero/printer.py b/fichero/printer.py index 6a9da82..2f6644f 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -384,10 +384,25 @@ async def connect( if classic: if not address: raise PrinterError("--address is required for Classic Bluetooth (no scanning)") - async with RFCOMMClient(address, channel) as client: - pc = PrinterClient(client) - await pc.start() - yield pc + # 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: addr = address or await find_printer() try: diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md new file mode 100644 index 0000000..8438071 --- /dev/null +++ b/fichero_printer/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## 0.1.9 + +- Added add-on local changelog file so Home Assistant can display release notes. +- Improved Classic Bluetooth RFCOMM connection robustness by trying fallback channels (1-3 plus configured channel). + +## 0.1.8 + +- Added Home Assistant web print interface on `/` with status/info/text/image actions. + +## 0.1.7 + +- Fixed ingress Swagger OpenAPI loading behind Home Assistant. +- Enabled `full_access` for stricter hosts blocking RFCOMM sockets. + +## 0.1.6 + +- Added root changelog and release policy. + +## 0.1.5 + +- Added `NET_RAW` capability for Classic Bluetooth sockets. + +## 0.1.4 + +- Fixed RFCOMM connect path under uvloop. + +## 0.1.3 + +- Added ingress/webui metadata updates. + diff --git a/fichero_printer/DOCS.md b/fichero_printer/DOCS.md index 82e51b5..afeb4dc 100644 --- a/fichero_printer/DOCS.md +++ b/fichero_printer/DOCS.md @@ -22,7 +22,7 @@ direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen. | `port` | `8765` | Port des REST-API-Servers (auch im „Port-Mapping" oben anpassen) | | `ble_address` | _(leer)_ | Feste BLE-Adresse des Druckers (z.B. `AA:BB:CC:DD:EE:FF`). Leer lassen für automatischen Scan. | | `transport` | `ble` | Verbindungsart: `ble` (Bluetooth Low Energy) oder `classic` (RFCOMM) | -| `channel` | `1` | RFCOMM-Kanal – nur relevant bei `transport: classic` | +| `channel` | `1` | RFCOMM-Kanal – nur relevant bei `transport: classic` (bei Fehlern werden zusätzlich typische Kanäle getestet) | ## Verwendung diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 4edbadc..b8e3879 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.8" +version: "0.1.9" 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/printer.py b/fichero_printer/fichero/printer.py index 6a9da82..2f6644f 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -384,10 +384,25 @@ async def connect( if classic: if not address: raise PrinterError("--address is required for Classic Bluetooth (no scanning)") - async with RFCOMMClient(address, channel) as client: - pc = PrinterClient(client) - await pc.start() - yield pc + # 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: addr = address or await find_printer() try: diff --git a/pyproject.toml b/pyproject.toml index b881a46..bf4ef25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.8" +version = "0.1.9" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 4dd04d1d34d6154802d9fda06ecd995aa9a9771d Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 14:29:32 +0100 Subject: [PATCH 16/64] Add printer scanning functionality and enhance UI for address input --- fichero_printer/fichero/api.py | 45 +++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index a96eb7b..0c860ce 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -35,6 +35,7 @@ from fichero.printer import ( PrinterNotFound, PrinterTimeout, connect, + find_printer, ) # --------------------------------------------------------------------------- @@ -217,7 +218,10 @@ def _ui_html() -> str:

Connection

- +
+ + +
@@ -352,6 +356,29 @@ def _ui_html() -> str: await showResponse(response); }} + async function scanAddress() {{ + const btn = document.getElementById("scan-btn"); + const output = document.getElementById("output"); + btn.disabled = true; + btn.textContent = "Scanning…"; + output.textContent = "Scanning for printer (up to 8 s)…"; + try {{ + const response = await fetch("scan"); + const data = await response.json(); + if (response.ok && data.address) {{ + document.getElementById("address").value = data.address; + output.textContent = JSON.stringify({{ status: response.status, ok: true, data }}, null, 2); + }} else {{ + output.textContent = JSON.stringify({{ status: response.status, ok: false, data }}, null, 2); + }} + }} catch (e) {{ + output.textContent = "Scan failed: " + e; + }} finally {{ + btn.disabled = false; + btn.innerHTML = "🔌 Scan"; + }} + }} + async function printText() {{ const form = new FormData(); form.set("text", document.getElementById("text").value); @@ -410,6 +437,22 @@ async def docs(): ) +@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", From 8513afe831f3599e6cd1ba92e93ef8d6e094541e Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 14:29:44 +0100 Subject: [PATCH 17/64] Add BLE retry/backoff for connection timeouts and bump to 0.1.10 --- CHANGELOG.md | 5 +++ fichero/printer.py | 51 ++++++++++++++++++++++-------- fichero_printer/CHANGELOG.md | 5 ++- fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 51 ++++++++++++++++++++++-------- pyproject.toml | 2 +- 6 files changed, 85 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b404860..38c03e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.10] - 2026-03-07 + +### Changed +- Added automatic BLE reconnect retry with linear backoff for transient timeout errors (including `br-connection-timeout`) before returning a failure. + ## [0.1.9] - 2026-03-07 ### Added diff --git a/fichero/printer.py b/fichero/printer.py index 2f6644f..0773645 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -53,6 +53,8 @@ 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 --- @@ -405,17 +407,38 @@ async def connect( raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") else: addr = address or await find_printer() - try: - async with BleakClient(addr) as client: - pc = PrinterClient(client) - await pc.start() - yield pc - except BleakDBusError as exc: - if "br-connection-not-supported" in str(exc).lower(): - raise PrinterError( - "BLE connection failed (br-connection-not-supported). " - "Try Classic Bluetooth with classic=true and channel=1." - ) from exc - raise PrinterError(f"BLE connection failed: {exc}") from exc - except BleakError as exc: - raise PrinterError(f"BLE error: {exc}") from exc + 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")) + + last_exc: Exception | None = None + for attempt in range(1, BLE_CONNECT_RETRIES + 1): + try: + async with BleakClient(addr) as client: + pc = PrinterClient(client) + await pc.start() + yield pc + return + except BleakDBusError as exc: + msg = str(exc).lower() + if "br-connection-not-supported" in msg: + raise PrinterError( + "BLE connection failed (br-connection-not-supported). " + "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/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 8438071..39d2acb 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.10 + +- Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`). + ## 0.1.9 - Added add-on local changelog file so Home Assistant can display release notes. @@ -29,4 +33,3 @@ ## 0.1.3 - Added ingress/webui metadata updates. - diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index b8e3879..f9774b2 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.9" +version: "0.1.10" 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/printer.py b/fichero_printer/fichero/printer.py index 2f6644f..0773645 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -53,6 +53,8 @@ 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 --- @@ -405,17 +407,38 @@ async def connect( raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") else: addr = address or await find_printer() - try: - async with BleakClient(addr) as client: - pc = PrinterClient(client) - await pc.start() - yield pc - except BleakDBusError as exc: - if "br-connection-not-supported" in str(exc).lower(): - raise PrinterError( - "BLE connection failed (br-connection-not-supported). " - "Try Classic Bluetooth with classic=true and channel=1." - ) from exc - raise PrinterError(f"BLE connection failed: {exc}") from exc - except BleakError as exc: - raise PrinterError(f"BLE error: {exc}") from exc + 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")) + + last_exc: Exception | None = None + for attempt in range(1, BLE_CONNECT_RETRIES + 1): + try: + async with BleakClient(addr) as client: + pc = PrinterClient(client) + await pc.start() + yield pc + return + except BleakDBusError as exc: + msg = str(exc).lower() + if "br-connection-not-supported" in msg: + raise PrinterError( + "BLE connection failed (br-connection-not-supported). " + "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/pyproject.toml b/pyproject.toml index bf4ef25..7ae7e1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.9" +version = "0.1.10" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 7317a6081828ee770efac58fb43009bbf05f85e2 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 14:30:03 +0100 Subject: [PATCH 18/64] Bump version to 0.1.9 in API and package.json --- fichero/api.py | 2 +- fichero_printer/fichero/api.py | 2 +- web/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fichero/api.py b/fichero/api.py index a96eb7b..ed00108 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -72,7 +72,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.0", + version="0.1.9", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index 0c860ce..68f5f51 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -73,7 +73,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.0", + version="0.1.9", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/web/package.json b/web/package.json index f30ccf8..7ef8a34 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "fichero-web", "private": true, "type": "module", - "version": "0.0.1", + "version": "0.1.9", "scripts": { "dev": "vite", "build": "vite build", From 822dbd35b254d4d667877bdad97de091b01fd0ed Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 14:40:12 +0100 Subject: [PATCH 19/64] Handle BLE connect TimeoutError and bump to 0.1.11 --- CHANGELOG.md | 5 +++++ fichero/printer.py | 6 ++++++ fichero_printer/CHANGELOG.md | 4 ++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 6 ++++++ pyproject.toml | 2 +- 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c03e1..185019c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.11] - 2026-03-07 + +### Fixed +- Handled `asyncio.TimeoutError` from BLE connect path so connection timeouts now return mapped API errors (502) instead of unhandled 500 exceptions. + ## [0.1.10] - 2026-03-07 ### Changed diff --git a/fichero/printer.py b/fichero/printer.py index 0773645..821b492 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -419,6 +419,12 @@ async def connect( 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: diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 39d2acb..c2c856b 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.11 + +- Fixed unhandled BLE connect timeout (`asyncio.TimeoutError`) that previously caused HTTP 500 responses. + ## 0.1.10 - Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`). diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index f9774b2..5286365 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.10" +version: "0.1.11" 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/printer.py b/fichero_printer/fichero/printer.py index 0773645..821b492 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -419,6 +419,12 @@ async def connect( 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: diff --git a/pyproject.toml b/pyproject.toml index 7ae7e1d..804cde7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.10" +version = "0.1.11" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 6b6d57bd776116870b619f8210939ad26d373a10 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 15:07:53 +0100 Subject: [PATCH 20/64] Prefer BLE device object resolution and bump to 0.1.12 --- CHANGELOG.md | 5 +++++ fichero/printer.py | 21 +++++++++++++++++++-- fichero_printer/CHANGELOG.md | 4 ++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 21 +++++++++++++++++++-- pyproject.toml | 2 +- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185019c..e29a1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.12] - 2026-03-07 + +### Fixed +- BLE target resolution now prefers discovered Bleak device objects (instead of raw address strings), improving BlueZ LE connection handling on hosts that previously returned `br-connection-not-supported`. + ## [0.1.11] - 2026-03-07 ### Fixed diff --git a/fichero/printer.py b/fichero/printer.py index 821b492..a9fa089 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -90,6 +90,23 @@ async def find_printer() -> str: 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) + return device or address + devices = await BleakScanner.discover(timeout=8) + for d in devices: + if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES): + print(f" Found {d.name} at {d.address}") + return d + raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") + + # --- Status --- @@ -406,7 +423,7 @@ async def connect( ) from last_exc raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") else: - addr = address or await find_printer() + 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")) @@ -414,7 +431,7 @@ async def connect( last_exc: Exception | None = None for attempt in range(1, BLE_CONNECT_RETRIES + 1): try: - async with BleakClient(addr) as client: + async with BleakClient(target) as client: pc = PrinterClient(client) await pc.start() yield pc diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index c2c856b..874fed6 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.12 + +- Improved BLE connection target resolution by preferring discovered BLE device objects over raw MAC strings to avoid BlueZ `br-connection-not-supported` on some hosts. + ## 0.1.11 - Fixed unhandled BLE connect timeout (`asyncio.TimeoutError`) that previously caused HTTP 500 responses. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 5286365..8189e2b 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.11" +version: "0.1.12" 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/printer.py b/fichero_printer/fichero/printer.py index 821b492..a9fa089 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -90,6 +90,23 @@ async def find_printer() -> str: 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) + return device or address + devices = await BleakScanner.discover(timeout=8) + for d in devices: + if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES): + print(f" Found {d.name} at {d.address}") + return d + raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") + + # --- Status --- @@ -406,7 +423,7 @@ async def connect( ) from last_exc raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") else: - addr = address or await find_printer() + 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")) @@ -414,7 +431,7 @@ async def connect( last_exc: Exception | None = None for attempt in range(1, BLE_CONNECT_RETRIES + 1): try: - async with BleakClient(addr) as client: + async with BleakClient(target) as client: pc = PrinterClient(client) await pc.start() yield pc diff --git a/pyproject.toml b/pyproject.toml index 804cde7..8ee907c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.11" +version = "0.1.12" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 9d77fbe3665be914343ef0d918a3a0365b37a901 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 15:11:03 +0100 Subject: [PATCH 21/64] Enhance UI layout and styles for LabelDesigner and MainPage components --- web/src/components/LabelDesigner.svelte | 24 +++--- web/src/components/MainPage.svelte | 107 ++++++++++++------------ web/src/styles/theme.scss | 102 ++++++++++++++++++++++ 3 files changed, 170 insertions(+), 63 deletions(-) diff --git a/web/src/components/LabelDesigner.svelte b/web/src/components/LabelDesigner.svelte index e90bd10..8c259a0 100644 --- a/web/src/components/LabelDesigner.svelte +++ b/web/src/components/LabelDesigner.svelte @@ -436,17 +436,19 @@
-
+
-
-
+
-
+
-
+ {#if selectedCount > 0 || selectedObject} +
-
+
{#if selectedCount > 0}
+ {/if} {#if previewOpened} .canvas-wrapper { - border: 1px solid var(--border-standard); background-color: var(--surface-1); } .canvas-wrapper.print-start-left { - border-left: 2px solid var(--mark-feed); + border-left: 3px solid var(--mark-feed); } .canvas-wrapper.print-start-top { - border-top: 2px solid var(--mark-feed); + border-top: 3px solid var(--mark-feed); } .canvas-wrapper canvas { image-rendering: pixelated; + display: block; } diff --git a/web/src/components/MainPage.svelte b/web/src/components/MainPage.svelte index c8c2c3b..d3c545b 100644 --- a/web/src/components/MainPage.svelte +++ b/web/src/components/MainPage.svelte @@ -14,78 +14,79 @@ let debugStuffShow = $state(false); -
-
-
-

- -

-
-
- -
-
-
-
- -
-
+
+
+
-
-
- -
-
-
- - +
+ +
+ +
+ + {#if debugStuffShow} {/if} diff --git a/web/src/styles/theme.scss b/web/src/styles/theme.scss index 48edb07..dfa90eb 100644 --- a/web/src/styles/theme.scss +++ b/web/src/styles/theme.scss @@ -209,3 +209,105 @@ --bs-progress-bg: var(--surface-1); --bs-progress-bar-bg: var(--fichero); } + +// ── Body background ──────────────────────────────────────────── + +body { + min-height: 100dvh; + background-image: + radial-gradient(ellipse at 15% 85%, rgba(var(--fichero-rgb), 0.06) 0%, transparent 55%), + radial-gradient(ellipse at 85% 8%, rgba(var(--fichero-rgb), 0.04) 0%, transparent 55%); + background-attachment: fixed; +} + +// ── App header ───────────────────────────────────────────────── + +.app-header { + position: sticky; + top: 0; + z-index: 1030; + padding: 7px 0; + background: rgba(22, 24, 25, 0.82); + backdrop-filter: blur(16px) saturate(1.5); + -webkit-backdrop-filter: blur(16px) saturate(1.5); + border-bottom: 1px solid var(--border-standard); + box-shadow: 0 1px 0 0 var(--border-soft); +} + +.app-brand { + display: flex; + align-items: center; + gap: 9px; + text-decoration: none; + color: inherit; + + &:hover { + color: inherit; + } +} + +.app-brand-logo { + height: 1.75em; + border-radius: 5px; + box-shadow: 0 0 0 1px var(--border-soft); +} + +.app-brand-name { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.025em; + color: var(--ink-primary); + + em { + color: var(--fichero); + font-style: normal; + } +} + +// ── Toolbar bar ──────────────────────────────────────────────── + +.toolbar-bar { + background: var(--surface-1); + border: 1px solid var(--border-standard); + border-radius: var(--radius-lg); + padding: 7px 12px; + + .btn-sm { + border-radius: var(--radius-sm) !important; + } +} + +// ── Canvas panel ─────────────────────────────────────────────── + +.canvas-panel { + display: inline-flex; + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: + 0 0 0 1px var(--border-standard), + 0 16px 48px rgba(0, 0, 0, 0.40), + 0 4px 12px rgba(0, 0, 0, 0.25); +} + +// ── Scrollbar ───────────────────────────────────────────────── + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--surface-0); } +::-webkit-scrollbar-thumb { + background: var(--surface-3); + border-radius: 3px; + + &:hover { background: var(--ink-muted); } +} + +// ── Transition helpers ───────────────────────────────────────── + +.btn { transition: background-color 0.15s, box-shadow 0.15s, border-color 0.15s; } + +.btn-primary:not(:disabled):hover { + box-shadow: 0 0 0 3px rgba(var(--fichero-rgb), 0.25); +} + +.btn-danger:not(:disabled):hover { + box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.25); +} From 42e56e1b9f2892241c90988e8bdd7c583a906a34 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 15:12:56 +0100 Subject: [PATCH 22/64] Retry BLE service-discovery disconnect errors and bump to 0.1.13 --- CHANGELOG.md | 5 +++++ fichero/printer.py | 11 ++++++++++- fichero_printer/CHANGELOG.md | 4 ++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 11 ++++++++++- pyproject.toml | 2 +- 6 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e29a1f2..3ba2329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.13] - 2026-03-07 + +### Fixed +- Treated BLE service-discovery disconnects (`failed to discover services, device disconnected`) as retryable transient errors in the BLE connect loop. + ## [0.1.12] - 2026-03-07 ### Fixed diff --git a/fichero/printer.py b/fichero/printer.py index a9fa089..be6f18f 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -426,7 +426,16 @@ async def connect( 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")) + 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 for attempt in range(1, BLE_CONNECT_RETRIES + 1): diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 874fed6..72089ce 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.13 + +- Marked BLE service-discovery disconnect errors as retryable (`failed to discover services, device disconnected`), so the add-on retries automatically. + ## 0.1.12 - Improved BLE connection target resolution by preferring discovered BLE device objects over raw MAC strings to avoid BlueZ `br-connection-not-supported` on some hosts. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 8189e2b..66475d0 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.12" +version: "0.1.13" 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/printer.py b/fichero_printer/fichero/printer.py index a9fa089..be6f18f 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -426,7 +426,16 @@ async def connect( 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")) + 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 for attempt in range(1, BLE_CONNECT_RETRIES + 1): diff --git a/pyproject.toml b/pyproject.toml index 8ee907c..49716c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.12" +version = "0.1.13" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 9f191b564ac9e6697ed13904bae37e8522ef3edc Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 7 Mar 2026 15:19:03 +0100 Subject: [PATCH 23/64] Bump version to 0.1.13 in API and package.json --- fichero/api.py | 2 +- fichero_printer/fichero/api.py | 698 ++++++++++++++++++++------------- web/package.json | 2 +- 3 files changed, 438 insertions(+), 264 deletions(-) diff --git a/fichero/api.py b/fichero/api.py index ed00108..75aee00 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -72,7 +72,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.9", + version="0.1.13", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index 68f5f51..3e2d06b 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -73,7 +73,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.9", + version="0.1.13", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -96,324 +96,498 @@ def _ui_html() -> str: default_address = _DEFAULT_ADDRESS or "" default_transport = "classic" if _DEFAULT_CLASSIC else "ble" return f""" - + Fichero Printer -
-
-

Fichero Printer

-

Home Assistant print console for status, text labels, and image uploads.

-

API docs remain available at /docs.

-
-
-
-

Connection

- -
- - -
+
+ FicheroPrinter +
+ + + Unknown + + +
+
-
-
- - -
-
- - -
-
+
-
- - -
-
+ +
+

Connection

-
-

Output

-
Ready.
-
+ +
+ + +
-
-

Print Text

- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - + + -
- -
+
+ + +
+
-
-

Print Image

- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- +
+ + +
+
+ + +
+

Response

+
Waiting for a command…
+
+ + +
+

Print text

+ + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + + +
+ +
+
+ + +
+

Print image

+ + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ -
- -
-
-
+
- + // ── Print text ─────────────────────────────────────── + async function printText() {{ + const form = new FormData(); + form.set("text", document.getElementById("text").value); + form.set("density", document.getElementById("text_density").value); + form.set("copies", document.getElementById("text_copies").value); + form.set("font_size", document.getElementById("text_font_size").value); + form.set("label_length", document.getElementById("text_label_length").value); + form.set("paper", document.getElementById("text_paper").value); + form.set("address", document.getElementById("address").value.trim()); + form.set("classic", String(document.getElementById("transport").value === "classic")); + form.set("channel", document.getElementById("channel").value); + setOutput({{ message: "Sending to printer…" }}, true); + const r = await fetch("print/text", {{ method: "POST", body: form }}); + await showResponse(r); + if (r.ok) refreshStatus(); + }} + + // ── Print image ────────────────────────────────────── + async function printImage() {{ + const fi = document.getElementById("image_file"); + if (!fi.files.length) {{ setOutput({{ error: "Select an image file first." }}, false); return; }} + const form = new FormData(); + form.set("file", fi.files[0]); + form.set("density", document.getElementById("image_density").value); + form.set("copies", document.getElementById("image_copies").value); + form.set("label_length", document.getElementById("image_label_length").value); + form.set("paper", document.getElementById("image_paper").value); + form.set("dither", String(document.getElementById("image_dither").checked)); + form.set("address", document.getElementById("address").value.trim()); + form.set("classic", String(document.getElementById("transport").value === "classic")); + form.set("channel", document.getElementById("channel").value); + setOutput({{ message: "Sending to printer…" }}, true); + const r = await fetch("print/image", {{ method: "POST", body: form }}); + await showResponse(r); + if (r.ok) refreshStatus(); + }} + """ diff --git a/web/package.json b/web/package.json index 7ef8a34..9163795 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "fichero-web", "private": true, "type": "module", - "version": "0.1.9", + "version": "0.1.13", "scripts": { "dev": "vite", "build": "vite build", From 92a722477416fd29075e5a6ea12ca57e5fc696e5 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 22:50:57 +0100 Subject: [PATCH 24/64] Avoid raw MAC BLE fallback and bump to 0.1.14 --- CHANGELOG.md | 5 +++++ fichero/printer.py | 14 +++++++++++++- fichero_printer/CHANGELOG.md | 4 ++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 14 +++++++++++++- pyproject.toml | 2 +- 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba2329..e51400f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.14] - 2026-03-07 + +### Fixed +- Removed BLE fallback to raw MAC string when device resolution fails. The connector now requires a discovered LE device object, avoiding BlueZ BR/EDR misclassification that can cause `br-connection-not-supported`. + ## [0.1.13] - 2026-03-07 ### Fixed diff --git a/fichero/printer.py b/fichero/printer.py index be6f18f..38773e4 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -98,7 +98,19 @@ async def resolve_ble_target(address: str | None = None): """ if address: device = await BleakScanner.find_device_by_address(address, timeout=8.0) - return device or address + 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): diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 72089ce..62d281c 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.14 + +- Prevented BLE fallback to raw MAC connects and now require discovered LE device resolution, reducing `br-connection-not-supported` regressions on some BlueZ hosts. + ## 0.1.13 - Marked BLE service-discovery disconnect errors as retryable (`failed to discover services, device disconnected`), so the add-on retries automatically. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 66475d0..a0171a7 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.13" +version: "0.1.14" 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/printer.py b/fichero_printer/fichero/printer.py index be6f18f..38773e4 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -98,7 +98,19 @@ async def resolve_ble_target(address: str | None = None): """ if address: device = await BleakScanner.find_device_by_address(address, timeout=8.0) - return device or address + 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): diff --git a/pyproject.toml b/pyproject.toml index 49716c2..7ce7b9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.13" +version = "0.1.14" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 1a51ebb1225468f3e9d59a0208eb621c68430842 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Sat, 7 Mar 2026 22:56:13 +0100 Subject: [PATCH 25/64] Retry BLE with fresh LE scan on br-connection-not-supported (0.1.15) --- CHANGELOG.md | 5 +++++ fichero/printer.py | 10 +++++++++- fichero_printer/CHANGELOG.md | 4 ++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/printer.py | 10 +++++++++- pyproject.toml | 2 +- 6 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e51400f..38336eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.15] - 2026-03-07 + +### Fixed +- Added BLE recovery path for `br-connection-not-supported`: the connector now forces a fresh LE scan target resolution and retries before returning an error. + ## [0.1.14] - 2026-03-07 ### Fixed diff --git a/fichero/printer.py b/fichero/printer.py index 38773e4..7d44601 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -450,6 +450,7 @@ async def connect( ) 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: @@ -466,8 +467,15 @@ async def connect( 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). " + "BLE connection failed (br-connection-not-supported) after LE rescan. " "Try Classic Bluetooth with classic=true and channel=1." ) from exc last_exc = exc diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 62d281c..6dd5bad 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.15 + +- Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing. + ## 0.1.14 - Prevented BLE fallback to raw MAC connects and now require discovered LE device resolution, reducing `br-connection-not-supported` regressions on some BlueZ hosts. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index a0171a7..f36af81 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.14" +version: "0.1.15" 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/printer.py b/fichero_printer/fichero/printer.py index 38773e4..7d44601 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -450,6 +450,7 @@ async def connect( ) 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: @@ -466,8 +467,15 @@ async def connect( 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). " + "BLE connection failed (br-connection-not-supported) after LE rescan. " "Try Classic Bluetooth with classic=true and channel=1." ) from exc last_exc = exc diff --git a/pyproject.toml b/pyproject.toml index 7ce7b9a..4377d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.14" +version = "0.1.15" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 8520a8819712400159410e2c05de1f90ac26a9f4 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 10:15:22 +0100 Subject: [PATCH 26/64] refactor: Externalize web UI to index.html Refactors the embedded web UI in the API server to be loaded from a separate index.html file instead of a large inline string. This improves maintainability by separating the presentation layer (HTML/CSS/JS) from the backend Python logic. --- CHANGELOG.md | 30 +++ fichero/api.py | 394 +++++++++-------------------------- fichero/index.html | 307 +++++++++++++++++++++++++++ fichero_printer/CHANGELOG.md | 16 ++ 4 files changed, 453 insertions(+), 294 deletions(-) create mode 100644 fichero/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 38336eb..3990f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ 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.20] - 2026-03-08 + +### Changed + +- Refactored the embedded web UI in the API server to be loaded from a separate `index.html` file instead of a large inline string, improving maintainability. + +## [0.1.19] - 2026-03-08 + +### Added + +- Added `POST /unpair` endpoint and "Unpair Device" button in the web UI to remove a Bluetooth device from the host's paired devices. + +## [0.1.18] - 2026-03-08 + +### Added + +- Added `POST /pair` endpoint and "Pair Device" button in the web UI to easily pair/trust the printer via `bluetoothctl` for Classic Bluetooth connections. + +## [0.1.17] - 2026-03-08 + +### Added + +- Added automatic fallback to BLE connection if Classic Bluetooth (RFCOMM) fails with `[Errno 12] Out of memory`, a common issue on Linux with stale device states. + +## [0.1.16] - 2026-03-08 + +### Fixed + +- Corrected typos in the Code128B bit pattern table for characters '$' (ASCII 36) and ')' (ASCII 41), which caused incorrect barcodes to be generated. + ## [0.1.15] - 2026-03-07 ### Fixed diff --git a/fichero/api.py b/fichero/api.py index 75aee00..93c27ab 100644 --- a/fichero/api.py +++ b/fichero/api.py @@ -5,6 +5,7 @@ Start with: or: python -m fichero.api + Endpoints: GET /status – Printer status GET /info – Printer info (model, firmware, battery, …) @@ -17,8 +18,10 @@ 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 @@ -72,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.13", + version="0.1.20", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -94,301 +97,20 @@ def _address(address: str | None) -> str | None: def _ui_html() -> str: default_address = _DEFAULT_ADDRESS or "" default_transport = "classic" if _DEFAULT_CLASSIC else "ble" - return f""" - - - - - Fichero Printer - - - -
-
-

Fichero Printer

-

Home Assistant print console for status, text labels, and image uploads.

-

API docs remain available at /docs.

-
-
-
-

Connection

- - + try: + template_path = Path(__file__).parent / "index.html" + template = template_path.read_text(encoding="utf-8") + except FileNotFoundError: + return "

Error: index.html not found

" -
-
- - -
-
- - -
-
- -
- - -
-
- -
-

Output

-
Ready.
-
- -
-

Print Text

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

Print Image

- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
- - - -""" + # 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)) + ) # --------------------------------------------------------------------------- @@ -468,6 +190,90 @@ async def get_info( 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", diff --git a/fichero/index.html b/fichero/index.html new file mode 100644 index 0000000..a0f927e --- /dev/null +++ b/fichero/index.html @@ -0,0 +1,307 @@ + + + + + + Fichero Printer + + + +
+
+

Fichero Printer

+

Home Assistant print console for status, text labels, and image uploads.

+

API docs remain available at /docs.

+
+ +
+
+

Connection

+ + + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+ +
+

Output

+
Ready.
+
+ +
+

Print Text

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Print Image

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 6dd5bad..9b9aa1a 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.1.20 + +- Refactored the embedded web UI to be loaded from an external `index.html` file. + +## 0.1.19 + +- Added "Unpair Device" button to the web UI. + +## 0.1.18 + +- Added "Pair Device" button to the web UI. + +## 0.1.16 + +- Added automatic fallback to BLE if Classic Bluetooth fails with `[Errno 12] Out of memory`. + ## 0.1.15 - Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing. From 16886bfa2159f83680a0640476ba727f3f45891d Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 10:16:12 +0100 Subject: [PATCH 27/64] add files --- README.md | 12 ++++++++++++ fichero/printer.py | 32 +++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f8ff60e..a06ab02 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,18 @@ asyncio.run(main()) The package exports `PrinterClient`, `connect`, `PrinterError`, `PrinterNotFound`, `PrinterTimeout`, `PrinterNotReady`, and `PrinterStatus`. +## Troubleshooting + +### Classic Bluetooth: [Errno 12] Out of memory + +If you encounter `[Errno 12] Out of memory` failures on Classic Bluetooth connections, it typically implies a stale state in the BlueZ stack or the printer's radio. As of v0.1.17, the library automatically falls back to a BLE connection when this specific error occurs. + +If you wish to resolve the underlying Classic Bluetooth issue, these steps can help: + +- **Power cycle the printer**: This clears the printer's radio state and is often the only fix if the device is rejecting RFCOMM. +- **Verify Pairing**: Classic Bluetooth (RFCOMM) requires the device to be paired and trusted in the OS. You can use the "Pair Device" or "Unpair Device" buttons in the Home Assistant add-on's web UI, or run `bluetoothctl pair ` and `bluetoothctl trust ` (or `bluetoothctl remove `) on the host. Pairing is not required for BLE. +- **Restart Bluetooth**: `systemctl restart bluetooth` on the host can clear stuck socket handles. + ## 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. diff --git a/fichero/printer.py b/fichero/printer.py index 7d44601..3ae4f18 100644 --- a/fichero/printer.py +++ b/fichero/printer.py @@ -8,6 +8,7 @@ Device class: AiYinNormalDevice (LuckPrinter SDK) import asyncio import sys +import errno from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -427,14 +428,31 @@ async def connect( yield pc return except (PrinterError, PrinterTimeout) as exc: + # On Linux, a stale BlueZ device state can cause RFCOMM connect() + # to fail with [Errno 12] Out of memory. This is a known quirk. + # We treat this specific error as a signal to fall back to BLE. + if isinstance(exc.__cause__, OSError) and exc.__cause__.errno == errno.ENOMEM: + print( + "Classic Bluetooth connection failed with [Errno 12] Out of memory. " + "Falling back to BLE connection." + ) + classic = False # Modify flag to trigger BLE path below + last_exc = exc + break 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: + + # If the 'classic' flag is still true, it means the loop completed without + # hitting the ENOMEM fallback case, so all classic attempts failed. + if classic: + if last_exc is not None: + raise PrinterError( + f"Classic Bluetooth connection failed for '{address}'. " + f"Tried channels: {channels}. Last error: {last_exc}" + ) from last_exc + raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") + + # If classic=False initially, or if it was set to False for the ENOMEM fallback: + if not classic: target = await resolve_ble_target(address) def _is_retryable_ble_error(exc: Exception) -> bool: msg = str(exc).lower() From eee58431abb8b63b5f2d2880d7b45ea610c866aa Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 16 Mar 2026 09:26:06 +0000 Subject: [PATCH 28/64] update version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4377d02..59d8a60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fichero-printer" -version = "0.1.15" +version = "0.1.20" description = "Fichero D11s thermal label printer - BLE CLI tool" requires-python = ">=3.10" dependencies = [ From 7843a384073d843934b292d4ba66a0ccdf1c2f96 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 12:43:26 +0100 Subject: [PATCH 29/64] refactor --- CHANGELOG.md | 18 + fichero/api.py | 2 +- fichero_printer/Dockerfile | 27 +- fichero_printer/config.yaml | 2 +- fichero_printer/fichero/__init__.py | 25 - fichero_printer/fichero/api.py | 829 --------------------------- fichero_printer/fichero/cli.py | 251 -------- fichero_printer/fichero/imaging.py | 97 ---- fichero_printer/fichero/printer.py | 496 ---------------- fichero_printer/translations/de.yaml | 13 + fichero_printer/translations/en.yaml | 14 +- pyproject.toml | 35 +- 12 files changed, 70 insertions(+), 1739 deletions(-) delete mode 100644 fichero_printer/fichero/__init__.py delete mode 100644 fichero_printer/fichero/api.py delete mode 100644 fichero_printer/fichero/cli.py delete mode 100644 fichero_printer/fichero/imaging.py delete mode 100644 fichero_printer/fichero/printer.py create mode 100644 fichero_printer/translations/de.yaml 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 From fef3d18d3f5d676b19a70a0228925294029931f0 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 13:48:12 +0100 Subject: [PATCH 30/64] 0.1.25 --- CHANGELOG.md | 12 ++++++++++++ fichero/api.py | 2 +- fichero_printer/CHANGELOG.md | 16 ++++++++++++++++ fichero_printer/Dockerfile | 29 +++++++++++++++++++---------- fichero_printer/config.yaml | 2 +- fichero_printer/fichero | 1 + pyproject.toml | 2 +- 7 files changed, 51 insertions(+), 13 deletions(-) create mode 120000 fichero_printer/fichero diff --git a/CHANGELOG.md b/CHANGELOG.md index fbde396..c39818b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.25] - 2026-03-08 + +### Changed + +- **Build Process**: Replaced the manually copied `fichero` directory inside the Home Assistant add-on with a symbolic link. This eliminates code duplication and automates synchronization, simplifying the build process. + +## [0.1.24] - 2026-03-08 + +### Fixed + +- **Home Assistant Build**: Reverted the add-on's `Dockerfile` to a vendored code approach to resolve build failures caused by the Home Assistant build system's inability to access files outside the add-on directory. The add-on is now self-contained again. + ## [0.1.23] - 2026-03-08 ### Changed diff --git a/fichero/api.py b/fichero/api.py index 6c41d8a..8cb18a6 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.23", + version="0.1.25", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 9b9aa1a..2190451 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 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. + +## 0.1.23 + +- Updated `Dockerfile` to install the main library via `pip` instead of copying source files, completing the refactoring to eliminate duplicated code. + +## 0.1.22 + +- Refactored build process to install the main `fichero-printer` library as a package instead of using duplicated source files. This resolves issues with stale code. + +## 0.1.21 + +- Fixed stale source code issue by synchronizing the add-on's internal `fichero` package with the latest library version. + ## 0.1.20 - Refactored the embedded web UI to be loaded from an external `index.html` file. diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index 668b033..a96c3c1 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -1,8 +1,9 @@ ARG BUILD_FROM FROM $BUILD_FROM -# 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. +# Install system dependencies. +# build-base is for compiling Python packages (numpy, pillow). +# dbus-dev is for Bleak to communicate with the host's BlueZ. # Do NOT install bluez here - we use the host BlueZ, not our own. RUN apk add --no-cache \ bash \ @@ -11,17 +12,25 @@ RUN apk add --no-cache \ dbus-dev \ build-base -# Copy the entire project into the container. -# This requires the Docker build context to be the root of the repository. -WORKDIR /app -COPY . . +# Install Python dependencies from pip. +# We cannot use `pip install .` from pyproject.toml as it's outside the build context. +RUN pip3 install --no-cache-dir --break-system-packages \ + "bleak" \ + "numpy" \ + "Pillow" \ + "fastapi" \ + "uvicorn[standard]" \ + "python-multipart>=0.0.9" -# 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 the application code into the container. +WORKDIR /app +COPY fichero/ /app/fichero/ + +# Make the 'fichero' package importable. +ENV PYTHONPATH=/app # Copy startup script and normalise line endings (Windows CRLF -> LF) -COPY fichero_printer/run.sh /usr/bin/run.sh +COPY 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 7a04566..1d907ad 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.23" +version: "0.1.25" 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 new file mode 120000 index 0000000..43e0b2c --- /dev/null +++ b/fichero_printer/fichero @@ -0,0 +1 @@ +../fichero \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 021cdc5..c13c220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.23" +version = "0.1.25" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From 48c40d9f8fb6cb1eb92093ca2da9142938862421 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 14:17:49 +0100 Subject: [PATCH 31/64] 0.1.27 --- CHANGELOG.md | 13 + fichero/api.py | 2 +- fichero_printer/CHANGELOG.md | 3 + fichero_printer/config.yaml | 2 +- fichero_printer/fichero | 1 - .../fichero}/__init__.py | 0 fichero_printer/fichero/api.py | 418 ++++++++++++++++++ {fichero => fichero_printer/fichero}/cli.py | 0 .../fichero}/imaging.py | 0 .../fichero}/index.html | 0 .../fichero}/printer.py | 0 pyproject.toml | 4 +- sync_addon.sh | 0 13 files changed, 438 insertions(+), 5 deletions(-) delete mode 120000 fichero_printer/fichero rename {fichero => fichero_printer/fichero}/__init__.py (100%) create mode 100644 fichero_printer/fichero/api.py rename {fichero => fichero_printer/fichero}/cli.py (100%) rename {fichero => fichero_printer/fichero}/imaging.py (100%) rename {fichero => fichero_printer/fichero}/index.html (100%) rename {fichero => fichero_printer/fichero}/printer.py (100%) create mode 100644 sync_addon.sh 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 From 2a7915a7076f460d252227bab6fc9642efa74170 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 15:18:57 +0100 Subject: [PATCH 32/64] update to 0.1.28 --- CHANGELOG.md | 156 ------------------------------- README.md | 4 +- fichero_printer/CHANGELOG.md | 164 ++++++++++++++++++++++++--------- fichero_printer/Dockerfile | 3 +- fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 2 +- pyproject.toml | 2 +- 7 files changed, 125 insertions(+), 208 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217a05a..e69de29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,156 +0,0 @@ -# Changelog - -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 - -- **Build Process**: Replaced the manually copied `fichero` directory inside the Home Assistant add-on with a symbolic link. This eliminates code duplication and automates synchronization, simplifying the build process. - -## [0.1.24] - 2026-03-08 - -### Fixed - -- **Home Assistant Build**: Reverted the add-on's `Dockerfile` to a vendored code approach to resolve build failures caused by the Home Assistant build system's inability to access files outside the add-on directory. The add-on is now self-contained again. - -## [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 - -- Refactored the embedded web UI in the API server to be loaded from a separate `index.html` file instead of a large inline string, improving maintainability. - -## [0.1.19] - 2026-03-08 - -### Added - -- Added `POST /unpair` endpoint and "Unpair Device" button in the web UI to remove a Bluetooth device from the host's paired devices. - -## [0.1.18] - 2026-03-08 - -### Added - -- Added `POST /pair` endpoint and "Pair Device" button in the web UI to easily pair/trust the printer via `bluetoothctl` for Classic Bluetooth connections. - -## [0.1.17] - 2026-03-08 - -### Added - -- Added automatic fallback to BLE connection if Classic Bluetooth (RFCOMM) fails with `[Errno 12] Out of memory`, a common issue on Linux with stale device states. - -## [0.1.16] - 2026-03-08 - -### Fixed - -- Corrected typos in the Code128B bit pattern table for characters '$' (ASCII 36) and ')' (ASCII 41), which caused incorrect barcodes to be generated. - -## [0.1.15] - 2026-03-07 - -### Fixed -- Added BLE recovery path for `br-connection-not-supported`: the connector now forces a fresh LE scan target resolution and retries before returning an error. - -## [0.1.14] - 2026-03-07 - -### Fixed -- Removed BLE fallback to raw MAC string when device resolution fails. The connector now requires a discovered LE device object, avoiding BlueZ BR/EDR misclassification that can cause `br-connection-not-supported`. - -## [0.1.13] - 2026-03-07 - -### Fixed -- Treated BLE service-discovery disconnects (`failed to discover services, device disconnected`) as retryable transient errors in the BLE connect loop. - -## [0.1.12] - 2026-03-07 - -### Fixed -- BLE target resolution now prefers discovered Bleak device objects (instead of raw address strings), improving BlueZ LE connection handling on hosts that previously returned `br-connection-not-supported`. - -## [0.1.11] - 2026-03-07 - -### Fixed -- Handled `asyncio.TimeoutError` from BLE connect path so connection timeouts now return mapped API errors (502) instead of unhandled 500 exceptions. - -## [0.1.10] - 2026-03-07 - -### Changed -- Added automatic BLE reconnect retry with linear backoff for transient timeout errors (including `br-connection-timeout`) before returning a failure. - -## [0.1.9] - 2026-03-07 - -### Added -- Added add-on-local changelog at `fichero_printer/CHANGELOG.md` so Home Assistant can display release notes in the add-on UI. - -### Changed -- Improved Classic Bluetooth connect logic by trying fallback RFCOMM channels (1-3 plus configured channel) before failing. - -## [0.1.8] - 2026-03-07 - -### Added -- Root URL now serves a built-in printer web interface for Home Assistant with status, info, text printing, and image upload printing. - -### Changed -- Swagger docs remain available under `/docs` while the Home Assistant "Open" action now lands on the print UI. - -## [0.1.7] - 2026-03-07 - -### Fixed -- Home Assistant ingress docs now use a custom Swagger UI route with a relative `openapi.json` URL, avoiding `404 /openapi.json` behind ingress prefixes. - -### Changed -- Home Assistant add-on now requests `full_access: true` in addition to Bluetooth capabilities to unblock Classic RFCOMM socket access on stricter hosts. - -## [0.1.6] - 2026-03-07 - -### Added -- Added this `CHANGELOG.md` and established a release policy to update version and changelog for every change. - -## [0.1.5] - 2026-03-07 - -### Changed -- Home Assistant add-on now requests `NET_RAW` in addition to `NET_ADMIN` for Classic Bluetooth RFCOMM sockets. -- Add-on documentation updated with Classic permission requirements. - -## [0.1.4] - 2026-03-07 - -### Fixed -- RFCOMM connection under `uvloop` now uses direct Bluetooth socket connect in a worker thread, avoiding address-family resolution issues. -- Classic Bluetooth socket errors are mapped to API-safe printer errors instead of unhandled 500s. - -## [0.1.3] - 2026-03-07 - -### Changed -- Home Assistant add-on metadata updated for ingress/web UI access. -- API root endpoint now redirects to docs in an ingress-compatible way. -- Added attribution for original upstream project and AI-assisted extension note. diff --git a/README.md b/README.md index a06ab02..060cde6 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal lab - Original developer/project: [0xMH/fichero-printer](https://github.com/0xMH/fichero-printer) - This repository version was additionally extended with AI-assisted changes. -## Release Policy +## Development -- Maintain `CHANGELOG.md` for every user-visible change. +- The main changelog is located at `fichero_printer/CHANGELOG.md`. - Bump the project/add-on version with every merged change. Blog post: [Reverse Engineering Action's Cheap Fichero Labelprinter](https://blog.dbuglife.com/reverse-engineering-fichero-label-printer/) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 5e9a822..041bc33 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -1,90 +1,162 @@ # 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. +All notable changes to this project are documented in this file. -## 0.1.24 +The format is based on Keep a Changelog and this project uses Semantic Versioning. -- 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. +## [0.1.28] - 2026-03-16 -## 0.1.23 +### Fixed +- **Pairing**: Added the `bluez` package to the Docker image, which provides the `bluetoothctl` command. This fixes the "not found" error when using the "Pair Device" and "Unpair Device" features. +- **Changelog**: Consolidated the project's changelogs into a single, consistent file within the add-on, resolving inconsistencies from previous refactoring. -- Updated `Dockerfile` to install the main library via `pip` instead of copying source files, completing the refactoring to eliminate duplicated code. +## [0.1.27] - 2026-03-16 -## 0.1.22 +### Changed -- Refactored build process to install the main `fichero-printer` library as a package instead of using duplicated source files. This resolves issues with stale code. +- **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.21 +## [0.1.26] - 2026-03-16 -- Fixed stale source code issue by synchronizing the add-on's internal `fichero` package with the latest library version. +### Fixed -## 0.1.20 +- **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. -- Refactored the embedded web UI to be loaded from an external `index.html` file. +## [0.1.25] - 2026-03-08 -## 0.1.19 +### Changed -- Added "Unpair Device" button to the web UI. +- **Build Process**: Replaced the manually copied `fichero` directory inside the Home Assistant add-on with a symbolic link. This eliminates code duplication and automates synchronization, simplifying the build process. -## 0.1.18 +## [0.1.24] - 2026-03-08 -- Added "Pair Device" button to the web UI. +### Fixed -## 0.1.16 +- **Home Assistant Build**: Reverted the add-on's `Dockerfile` to a vendored code approach to resolve build failures caused by the Home Assistant build system's inability to access files outside the add-on directory. The add-on is now self-contained again. -- Added automatic fallback to BLE if Classic Bluetooth fails with `[Errno 12] Out of memory`. +## [0.1.23] - 2026-03-08 -## 0.1.15 +### Changed -- Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing. +- 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.14 +## [0.1.22] - 2026-03-08 -- Prevented BLE fallback to raw MAC connects and now require discovered LE device resolution, reducing `br-connection-not-supported` regressions on some BlueZ hosts. +### Changed -## 0.1.13 +- **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. -- Marked BLE service-discovery disconnect errors as retryable (`failed to discover services, device disconnected`), so the add-on retries automatically. +## [0.1.21] - 2026-03-08 -## 0.1.12 +### 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. -- Improved BLE connection target resolution by preferring discovered BLE device objects over raw MAC strings to avoid BlueZ `br-connection-not-supported` on some hosts. +## [0.1.20] - 2026-03-08 -## 0.1.11 +### Changed -- Fixed unhandled BLE connect timeout (`asyncio.TimeoutError`) that previously caused HTTP 500 responses. +- Refactored the embedded web UI in the API server to be loaded from a separate `index.html` file instead of a large inline string, improving maintainability. -## 0.1.10 +## [0.1.19] - 2026-03-08 -- Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`). +### Added -## 0.1.9 +- Added `POST /unpair` endpoint and "Unpair Device" button in the web UI to remove a Bluetooth device from the host's paired devices. -- Added add-on local changelog file so Home Assistant can display release notes. -- Improved Classic Bluetooth RFCOMM connection robustness by trying fallback channels (1-3 plus configured channel). +## [0.1.18] - 2026-03-08 -## 0.1.8 +### Added -- Added Home Assistant web print interface on `/` with status/info/text/image actions. +- Added `POST /pair` endpoint and "Pair Device" button in the web UI to easily pair/trust the printer via `bluetoothctl` for Classic Bluetooth connections. -## 0.1.7 +## [0.1.17] - 2026-03-08 -- Fixed ingress Swagger OpenAPI loading behind Home Assistant. -- Enabled `full_access` for stricter hosts blocking RFCOMM sockets. +### Added -## 0.1.6 +- Added automatic fallback to BLE connection if Classic Bluetooth (RFCOMM) fails with `[Errno 12] Out of memory`, a common issue on Linux with stale device states. -- Added root changelog and release policy. +## [0.1.16] - 2026-03-08 -## 0.1.5 +### Fixed -- Added `NET_RAW` capability for Classic Bluetooth sockets. +- Corrected typos in the Code128B bit pattern table for characters '$' (ASCII 36) and ')' (ASCII 41), which caused incorrect barcodes to be generated. -## 0.1.4 +## [0.1.15] - 2026-03-07 -- Fixed RFCOMM connect path under uvloop. +### Fixed +- Added BLE recovery path for `br-connection-not-supported`: the connector now forces a fresh LE scan target resolution and retries before returning an error. -## 0.1.3 +## [0.1.14] - 2026-03-07 -- Added ingress/webui metadata updates. +### Fixed +- Removed BLE fallback to raw MAC string when device resolution fails. The connector now requires a discovered LE device object, avoiding BlueZ BR/EDR misclassification that can cause `br-connection-not-supported`. + +## [0.1.13] - 2026-03-07 + +### Fixed +- Treated BLE service-discovery disconnects (`failed to discover services, device disconnected`) as retryable transient errors in the BLE connect loop. + +## [0.al.12] - 2026-03-07 + +### Fixed +- BLE target resolution now prefers discovered Bleak device objects (instead of raw address strings), improving BlueZ LE connection handling on hosts that previously returned `br-connection-not-supported`. + +## [0.1.11] - 2026-03-07 + +### Fixed +- Handled `asyncio.TimeoutError` from BLE connect path so connection timeouts now return mapped API errors (502) instead of unhandled 500 exceptions. + +## [0.1.10] - 2026-03-07 + +### Changed +- Added automatic BLE reconnect retry with linear backoff for transient timeout errors (including `br-connection-timeout`) before returning a failure. + +## [0.1.9] - 2026-03-07 + +### Added +- Added add-on-local changelog at `fichero_printer/CHANGELOG.md` so Home Assistant can display release notes in the add-on UI. + +### Changed +- Improved Classic Bluetooth connect logic by trying fallback RFCOMM channels (1-3 plus configured channel) before failing. + +## [0.1.8] - 2026-03-07 + +### Added +- Root URL now serves a built-in printer web interface for Home Assistant with status, info, text printing, and image upload printing. + +### Changed +- Swagger docs remain available under `/docs` while the Home Assistant "Open" action now lands on the print UI. + +## [0.1.7] - 2026-03-07 + +### Fixed +- Home Assistant ingress docs now use a custom Swagger UI route with a relative `openapi.json` URL, avoiding `404 /openapi.json` behind ingress prefixes. + +### Changed +- Home Assistant add-on now requests `full_access: true` in addition to Bluetooth capabilities to unblock Classic RFCOMM socket access on stricter hosts. + +## [0.1.6] - 2026-03-07 + +### Added +- Added this `CHANGELOG.md` and established a release policy to update version and changelog for every change. + +## [0.1.5] - 2026-03-07 + +### Changed +- Home Assistant add-on now requests `NET_RAW` in addition to `NET_ADMIN` for Classic Bluetooth RFCOMM sockets. +- Add-on documentation updated with Classic permission requirements. + +## [0.1.4] - 2026-03-07 + +### Fixed +- RFCOMM connection under `uvloop` now uses direct Bluetooth socket connect in a worker thread, avoiding address-family resolution issues. +- Classic Bluetooth socket errors are mapped to API-safe printer errors instead of unhandled 500s. + +## [0.1.3] - 2026-03-07 + +### Changed +- Home Assistant add-on metadata updated for ingress/web UI access. +- API root endpoint now redirects to docs in an ingress-compatible way. +- Added attribution for original upstream project and AI-assisted extension note. diff --git a/fichero_printer/Dockerfile b/fichero_printer/Dockerfile index a96c3c1..4f0b216 100644 --- a/fichero_printer/Dockerfile +++ b/fichero_printer/Dockerfile @@ -10,7 +10,8 @@ RUN apk add --no-cache \ python3 \ py3-pip \ dbus-dev \ - build-base + build-base \ + bluez # Install Python dependencies from pip. # We cannot use `pip install .` from pyproject.toml as it's outside the build context. diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index c2d956f..e07b051 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.27" +version: "0.1.28" 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/api.py b/fichero_printer/fichero/api.py index 0dace79..5ce8389 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/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.26", + version="0.1.28", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/pyproject.toml b/pyproject.toml index 82f32ec..c754e6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.27" +version = "0.1.28" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From 4c1bedf1660d817503c49ae1dfeec3cc2b74e0d7 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 18:30:19 +0100 Subject: [PATCH 33/64] 0.1.29 --- fichero_printer/CHANGELOG.md | 5 +++++ fichero_printer/build.yaml | 3 --- fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 2 +- fichero_printer/fichero/printer.py | 19 ++++++++++++++++++- pyproject.toml | 2 +- 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 041bc33..9e18f48 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -4,6 +4,11 @@ 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.29] - 2026-03-16 + +### Fixed +- **BLE Connection**: Implemented a more robust recovery mechanism for `br-connection-not-supported` errors. The add-on will now automatically attempt to run `bluetoothctl remove` to clear the host's device cache before rescanning, which should improve connection reliability on affected systems. + ## [0.1.28] - 2026-03-16 ### Fixed diff --git a/fichero_printer/build.yaml b/fichero_printer/build.yaml index 48722ec..7cae12c 100644 --- a/fichero_printer/build.yaml +++ b/fichero_printer/build.yaml @@ -1,6 +1,3 @@ build_from: aarch64: "ghcr.io/home-assistant/aarch64-base:latest" amd64: "ghcr.io/home-assistant/amd64-base:latest" - armhf: "ghcr.io/home-assistant/armhf-base:latest" - armv7: "ghcr.io/home-assistant/armv7-base:latest" - i386: "ghcr.io/home-assistant/i386-base:latest" diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index e07b051..f0c7c17 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.28" +version: "0.1.29" 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/api.py b/fichero_printer/fichero/api.py index 5ce8389..55018e9 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/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.28", + version = "0.1.29", lifespan=lifespan, docs_url=None, redoc_url=None, diff --git a/fichero_printer/fichero/printer.py b/fichero_printer/fichero/printer.py index 3ae4f18..8a4c69e 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -487,8 +487,25 @@ async def connect( if "br-connection-not-supported" in msg: last_exc = exc if not forced_rescan_done: + print( + "BLE connection failed with 'br-connection-not-supported'. " + "Attempting to clear device cache with 'bluetoothctl remove' and rescan." + ) + # Aggressive recovery: try to remove the device from bluez's cache + if address: + try: + remove_cmd = f'echo -e "remove {address}\\nquit" | bluetoothctl' + proc = await asyncio.create_subprocess_shell( + remove_cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await asyncio.wait_for(proc.communicate(), timeout=10.0) + except Exception as remove_exc: + print(f" Failed to run 'bluetoothctl remove': {remove_exc}") + forced_rescan_done = True - target = await resolve_ble_target(None) + target = await resolve_ble_target(address) if attempt < BLE_CONNECT_RETRIES: await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt) continue diff --git a/pyproject.toml b/pyproject.toml index c754e6d..5859872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.28" +version = "0.1.29" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From a23c33e29384b0903fd49432a7282ebe5bbab456 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 19:01:14 +0100 Subject: [PATCH 34/64] 0.1.30 --- fichero_printer/CHANGELOG.md | 9 ++++ fichero_printer/fichero/api.py | 83 ++++++++++++++++++++++++++++-- fichero_printer/fichero/printer.py | 10 ++-- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 9e18f48..a52b579 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -4,6 +4,15 @@ 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.30] - 2026-03-16 + +### Fixed +- **BLE Connection**: Restored fallback to raw address string when BLE scan fails to find the specific device. This fixes connectivity for devices that are reachable but not advertising (e.g. during rapid reconnection or BlueZ cache issues), resolving "BLE device not found during scan" errors. + +### Added +- **Web UI**: Restored support for the modern, responsive web interface. If the build artifacts are present in `fichero/web`, they will be served by default. +- **Web UI**: Added a `?legacy=true` query parameter to the root URL to force the simple server-side rendered UI, which includes the new debug scan tool. ## [0.1.29] - 2026-03-16 ### Fixed diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index 55018e9..fbedf20 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -27,8 +27,10 @@ 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 fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles from PIL import Image +from bleak import BleakScanner from fichero.cli import DOTS_PER_MM, do_print from fichero.imaging import text_to_image @@ -75,7 +77,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.29", + version = "0.1.30", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -88,6 +90,13 @@ app.add_middleware( allow_headers=["*"], ) +# Serve static files for the modern web UI (if built and present in 'web' dir) +_WEB_ROOT = Path(__file__).parent / "web" +if _WEB_ROOT.exists(): + # Typical SPA assets folder + if (_WEB_ROOT / "assets").exists(): + app.mount("/assets", StaticFiles(directory=_WEB_ROOT / "assets"), name="assets") + def _address(address: str | None) -> str | None: """Return the effective BLE address (request value overrides env default).""" @@ -105,21 +114,67 @@ def _ui_html() -> str: return "

Error: index.html not found

" # Simple substitution for initial values - return ( + template = ( 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)) ) + # Inject debug scan section and script + scan_html = """ +
+

Debug Scan

+

Scans for all nearby BLE devices to help with debugging connection issues.

+ +

+        
+ """ + scan_script = r''' + + ''' + # Inject before the closing tag + if "" in template: + parts = template.split("", 1) + template = parts[0] + scan_html + scan_script + "" + parts[1] + else: + # Fallback if no body tag + template += scan_html + scan_script + + return template + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @app.get("/", include_in_schema=False, response_class=HTMLResponse) -async def root(): +async def root(legacy: bool = False): """Serve a compact printer UI for Home Assistant.""" + # Prefer the modern SPA if available, unless ?legacy=true is used + if not legacy and (_WEB_ROOT / "index.html").exists(): + return HTMLResponse((_WEB_ROOT / "index.html").read_text(encoding="utf-8")) return HTMLResponse(_ui_html()) @@ -190,6 +245,26 @@ async def get_info( return info +@app.get( + "/scan", + summary="Scan for BLE devices", + response_description="List of discovered BLE devices", +) +async def scan_devices(): + """Scan for nearby BLE devices for 10 seconds for debugging.""" + try: + devices = await BleakScanner.discover(timeout=10.0) + return [ + {"address": d.address, "name": d.name or "N/A", "rssi": d.rssi} + for d in devices + ] + except Exception as exc: + # This provides more debug info to the user if scanning fails + raise HTTPException( + status_code=500, detail=f"An error occurred during BLE scanning: {exc}" + ) + + @app.post( "/pair", summary="Pair and trust a Bluetooth device", diff --git a/fichero_printer/fichero/printer.py b/fichero_printer/fichero/printer.py index 8a4c69e..0bee4fb 100644 --- a/fichero_printer/fichero/printer.py +++ b/fichero_printer/fichero/printer.py @@ -101,17 +101,13 @@ async def resolve_ble_target(address: str | None = None): 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. + # Fallback to active scan/match before giving up. devices = await BleakScanner.discover(timeout=8) for d in devices: if d.address and d.address.lower() == address.lower(): return d - raise PrinterNotFound( - f"BLE device {address} not found during scan. " - "Ensure printer is on, awake, and in range." - ) + print(f" Warning: BLE device {address} not found in scan. Falling back to direct address connection.") + return address devices = await BleakScanner.discover(timeout=8) for d in devices: if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES): From daacce46106299d58244ec0878631f66724f30a7 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Mon, 16 Mar 2026 21:50:03 +0100 Subject: [PATCH 35/64] update version --- fichero_printer/config.yaml | 6 ++---- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index f0c7c17..c647577 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.29" +version: "0.1.30" slug: "fichero_printer" description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth" url: "https://git.leuschner.dev/Tobias/Fichero" @@ -7,9 +7,7 @@ url: "https://git.leuschner.dev/Tobias/Fichero" arch: - aarch64 - amd64 - - armhf - - armv7 - - i386 + init: false startup: application diff --git a/pyproject.toml b/pyproject.toml index 5859872..62905d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.29" +version = "0.1.30" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From b8beb6a2b942d35309335702b8afc50b7f7277af Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 17:47:53 +0100 Subject: [PATCH 36/64] 0.1.31 --- fichero_printer/CHANGELOG.md | 17 ++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 57 +++-- fichero_printer/fichero/index.html | 322 +++++++++++++++++++++++++---- pyproject.toml | 2 +- 5 files changed, 348 insertions(+), 52 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index a52b579..016af9e 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -5,6 +5,23 @@ 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.31] - 2026-03-18 + +### Added +- **Web UI**: Completely modernized and responsive design with improved mobile support, smooth animations, and professional styling. +- **Web UI**: Enhanced debug scan section with loading indicators, signal strength visualization, and helpful troubleshooting tips. +- **Web UI**: Added loading spinners, status indicators, and better error messages throughout the interface. + +### Changed +- **Web UI**: Updated CSS with modern design system including CSS variables, transitions, and responsive breakpoints. +- **Web UI**: Improved button styling, card hover effects, and overall visual hierarchy. +- **Web UI**: Better mobile responsiveness with optimized touch targets and single-column layouts on small screens. + +### Fixed +- **Web UI**: Fixed scan section visibility and positioning - now properly integrated into main content area. +- **Web UI**: Improved scan button styling to match the modern design language. +- **Web UI**: Added proper CSS styling for the scan section instead of inline styles. + ## [0.1.30] - 2026-03-16 ### Fixed diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index c647577..7b626ef 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.30" +version: "0.1.31" 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/api.py b/fichero_printer/fichero/api.py index fbedf20..87b5072 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -77,7 +77,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.30", + version = "0.1.31", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -123,43 +123,74 @@ def _ui_html() -> str: # Inject debug scan section and script scan_html = """ -
+

Debug Scan

-

Scans for all nearby BLE devices to help with debugging connection issues.

- -

+            

Scans for all nearby BLE devices to help with debugging connection issues.

+ +

         
""" scan_script = r''' ''' - # Inject before the closing tag - if "" in template: + # Inject after the main content but before scripts + if "" in template: + parts = template.split("", 1) + template = parts[0] + "" + scan_html + parts[1] + elif "" in template: parts = template.split("", 1) template = parts[0] + scan_html + scan_script + "" + parts[1] else: - # Fallback if no body tag + # Fallback if no main or body tag template += scan_html + scan_script return template diff --git a/fichero_printer/fichero/index.html b/fichero_printer/fichero/index.html index a0f927e..7e011b6 100644 --- a/fichero_printer/fichero/index.html +++ b/fichero_printer/fichero/index.html @@ -13,99 +13,347 @@ --muted: #6c6258; --accent: #b55e33; --accent-2: #245b4b; + --success: #2e8b57; + --warning: #cd853f; + --shadow-sm: 0 1px 3px rgba(45, 36, 29, 0.1); + --shadow-md: 0 4px 12px rgba(45, 36, 29, 0.1); + --shadow-lg: 0 12px 24px rgba(45, 36, 29, 0.15); + --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } - * { box-sizing: border-box; } + + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + body { margin: 0; - font-family: "Noto Sans", system-ui, sans-serif; + font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", system-ui, sans-serif; color: var(--ink); background: radial-gradient(circle at top left, #fff8ed 0, transparent 35%), linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%); + min-height: 100vh; + line-height: 1.6; } + main { - max-width: 980px; + max-width: 1200px; margin: 0 auto; - padding: 24px 16px 40px; + padding: 2rem 1rem 3rem; } + .hero { - margin-bottom: 20px; - padding: 24px; + margin-bottom: 2rem; + padding: 2rem; border: 1px solid var(--line); - border-radius: 18px; - background: rgba(255, 250, 242, 0.92); - backdrop-filter: blur(4px); + border-radius: 20px; + background: rgba(255, 250, 242, 0.95); + backdrop-filter: blur(6px); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; } - h1, h2 { margin: 0 0 12px; } - .muted { color: var(--muted); } + + .hero::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + } + + h1 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + h2 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--ink); + border-bottom: 2px solid var(--line); + padding-bottom: 0.5rem; + } + + h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--ink); + } + + .muted { + color: var(--muted); + font-size: 0.9rem; + } + .grid { display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + margin-bottom: 2rem; } + .card { - padding: 18px; + padding: 1.5rem; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); - box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06); + box-shadow: var(--shadow-sm); + transition: var(--transition); } + + .card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); + } + + .card h3 { + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--line); + } + label { display: block; - margin: 10px 0 6px; - font-size: 0.92rem; + margin: 0.75rem 0 0.5rem; + font-size: 0.9rem; font-weight: 600; + color: var(--ink); } - input, select, textarea, button { + + input, select, textarea { width: 100%; border-radius: 10px; border: 1px solid var(--line); - padding: 10px 12px; + padding: 0.75rem 1rem; font: inherit; + font-size: 0.95rem; + background: white; + transition: var(--transition); } - textarea { min-height: 110px; resize: vertical; } + + input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(181, 94, 51, 0.1); + } + + textarea { + min-height: 120px; + resize: vertical; + font-family: inherit; + } + .row { display: grid; - gap: 12px; + gap: 1rem; grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 0.5rem; } + .inline { display: flex; - gap: 10px; + gap: 0.75rem; align-items: center; - margin-top: 12px; + margin-top: 1rem; } - .inline input[type="checkbox"] { width: auto; } + + .inline input[type="checkbox"] { + width: auto; + transform: scale(1.2); + } + button { cursor: pointer; - font-weight: 700; - color: #fff; + font-weight: 600; + color: white; background: var(--accent); border: none; + border-radius: 10px; + padding: 0.75rem 1.5rem; + font-size: 0.95rem; + transition: var(--transition); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; } - button.alt { background: var(--accent-2); } + + button:hover { + background: #a1542d; + transform: translateY(-1px); + } + + button:active { + transform: translateY(0); + } + + button.alt { + background: var(--accent-2); + } + + button.alt:hover { + background: #1f4e41; + } + + button.secondary { + background: var(--line); + color: var(--ink); + } + + button.secondary:hover { + background: #c5b8a8; + } + pre { overflow: auto; - margin: 0; - padding: 12px; + margin: 1rem 0; + padding: 1rem; border-radius: 12px; background: #241f1a; color: #f7efe4; - min-height: 140px; + min-height: 160px; + font-size: 0.9rem; + position: relative; } + + pre::before { + content: '$'; + position: absolute; + left: 1rem; + top: 1rem; + color: var(--muted); + font-weight: bold; + } + .actions { display: flex; - gap: 10px; + gap: 0.75rem; flex-wrap: wrap; - margin-top: 12px; + margin-top: 1.5rem; } + .actions button { - width: auto; - min-width: 140px; + flex: 1; + min-width: 160px; } - @media (max-width: 640px) { - .row { grid-template-columns: 1fr; } - .actions button { width: 100%; } + + @media (max-width: 768px) { + main { + padding: 1.5rem 1rem 2.5rem; + } + + .hero { + padding: 1.5rem; + } + + h1 { + font-size: 1.75rem; + } + + .grid { + grid-template-columns: 1fr; + } + + .row { + grid-template-columns: 1fr; + } + + .actions button { + width: 100%; + } + } + + @media (max-width: 480px) { + h1 { + font-size: 1.5rem; + } + + h2 { + font-size: 1.25rem; + } + + button { + padding: 0.6rem 1rem; + font-size: 0.9rem; + } + } + + /* Loading spinner */ + .loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 1s ease-in-out infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* Status indicators */ + .status { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 600; + margin-left: 0.5rem; + } + + .status.success { + background: rgba(46, 139, 87, 0.2); + color: var(--success); + } + + .status.error { + background: rgba(205, 133, 63, 0.2); + color: var(--warning); + } + + /* Tabs */ + .tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--line); + } + + .tab { + padding: 0.75rem 1rem; + cursor: pointer; + border: none; + background: transparent; + color: var(--muted); + font-weight: 500; + border-bottom: 2px solid transparent; + transition: var(--transition); + } + + .tab.active { + color: var(--ink); + border-bottom-color: var(--accent); + } + + .tab-content { + display: none; + } + + .tab-content.active { + display: block; } diff --git a/pyproject.toml b/pyproject.toml index 62905d7..66698b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.30" +version = "0.1.31" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From 7e2c841f81b108e884308b7b81c91dc4a3280489 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 18:02:26 +0100 Subject: [PATCH 37/64] v0.1.32: Fix scan functionality and add debug logging - Fixed scan functionality not showing output by ensuring JavaScript is properly injected - Added default hint text to scan results area - Added comprehensive debug console logging to help diagnose scan issues - Improved scan section injection logic - Updated version to 0.1.32 in all files Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- fichero_printer/CHANGELOG.md | 11 +++++++++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 13 ++++++++++--- pyproject.toml | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 016af9e..ba31512 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -5,6 +5,17 @@ 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.32] - 2026-03-18 + +### Fixed +- **Web UI**: Fixed scan functionality not showing output by ensuring JavaScript is properly injected in all cases. +- **Web UI**: Added default hint text to scan results area for better user guidance. +- **Web UI**: Added comprehensive debug console logging to help diagnose scan issues. + +### Changed +- **Web UI**: Improved scan section injection logic to work with both `` and `` templates. +- **Web UI**: Enhanced error handling and user feedback in scan functionality. + ## [0.1.31] - 2026-03-18 ### Added diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 7b626ef..59ba6b0 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.31" +version: "0.1.32" 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/api.py b/fichero_printer/fichero/api.py index 87b5072..35ec923 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -77,7 +77,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.31", + version = "0.1.32", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -130,23 +130,28 @@ def _ui_html() -> str: Scan for BLE Devices (10s) -

+            
📱 Click "Scan for BLE Devices" to search for nearby Bluetooth devices...
""" scan_script = r''' @@ -185,7 +192,7 @@ def _ui_html() -> str: # Inject after the main content but before scripts if "" in template: parts = template.split("", 1) - template = parts[0] + "" + scan_html + parts[1] + template = parts[0] + "" + scan_html + scan_script + parts[1] elif "" in template: parts = template.split("", 1) template = parts[0] + scan_html + scan_script + "" + parts[1] diff --git a/pyproject.toml b/pyproject.toml index 66698b9..9aecbb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.31" +version = "0.1.32" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From 620e433547f9ca7686cc18475cb36ca34409b1fd Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 18:05:26 +0100 Subject: [PATCH 38/64] v0.1.33: Fix scanForDevices is not defined error - Fixed JavaScript scope issue by injecting scan function into existing script section - Scan function now properly available when button is clicked - Improved JavaScript injection strategy to avoid separate script tag conflicts - Updated version to 0.1.33 in all files Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- fichero_printer/CHANGELOG.md | 10 ++++++++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 23 +++++++++++++++-------- pyproject.toml | 2 +- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index ba31512..802dc9f 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -5,6 +5,16 @@ 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.33] - 2026-03-18 + +### Fixed +- **Web UI**: Fixed "scanForDevices is not defined" error by injecting scan function into existing script section instead of separate script tag. +- **Web UI**: Ensured scan JavaScript is properly scoped and available when button is clicked. + +### Changed +- **Web UI**: Improved JavaScript injection strategy to avoid scope issues. +- **Web UI**: Scan function now injected directly into main script section for proper global availability. + ## [0.1.32] - 2026-03-18 ### Fixed diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 59ba6b0..04dd450 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.32" +version: "0.1.33" 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/api.py b/fichero_printer/fichero/api.py index 35ec923..1e52f5e 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -77,7 +77,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.32", + version = "0.1.33", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -134,7 +134,7 @@ def _ui_html() -> str:
""" scan_script = r''' - - ''' - # Inject after the main content but before scripts +''' + # Inject scan HTML after main content if "" in template: parts = template.split("", 1) - template = parts[0] + "" + scan_html + scan_script + parts[1] + template = parts[0] + "" + scan_html + parts[1] elif "" in template: parts = template.split("", 1) - template = parts[0] + scan_html + scan_script + "" + parts[1] + template = parts[0] + scan_html + "" + parts[1] else: # Fallback if no main or body tag - template += scan_html + scan_script + template += scan_html + + # Inject scan script before the closing tag of the main script + if "" in template: + parts = template.rsplit("", 1) + template = parts[0] + scan_script + "" + parts[1] + else: + # Fallback if no script tag found + template += f"" return template diff --git a/pyproject.toml b/pyproject.toml index 9aecbb2..7c779c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.32" +version = "0.1.33" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From c9de6af83ba858425a0bf3ca68f35a84e94845f2 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 18:09:43 +0100 Subject: [PATCH 39/64] v0.1.34: Fix URL resolution and JSON parsing errors - Fixed scan URL to use relative 'scan' instead of '/scan' for Home Assistant ingress - Improved error handling for non-JSON and malformed API responses - Added better debugging for JSON parsing errors - Updated version to 0.1.34 in all files Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- fichero_printer/CHANGELOG.md | 11 +++++++++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 21 ++++++++++++++++----- pyproject.toml | 2 +- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 802dc9f..33406b0 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -5,6 +5,17 @@ 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.34] - 2026-03-18 + +### Fixed +- **Web UI**: Fixed URL resolution issue - scan now uses relative URL 'scan' instead of '/scan' to work correctly with Home Assistant ingress. +- **Web UI**: Improved error handling for non-JSON responses and malformed API responses. +- **Web UI**: Added better debugging for JSON parsing errors. + +### Changed +- **Web UI**: Updated scan fetch call to match other API calls in the codebase. +- **Web UI**: Enhanced error messages to include raw response text when JSON parsing fails. + ## [0.1.33] - 2026-03-18 ### Fixed diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index 04dd450..d6ca277 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.33" +version: "0.1.34" 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/api.py b/fichero_printer/fichero/api.py index 1e52f5e..c55584c 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -77,7 +77,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.33", + version = "0.1.34", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -153,14 +153,25 @@ def _ui_html() -> str: console.log('Starting scan request...'); try { - const response = await fetch('/scan'); + const response = await fetch('scan'); if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || `HTTP error! status: ${response.status}`); + try { + const error = await response.json(); + throw new Error(error.detail || `HTTP error! status: ${response.status}`); + } catch (e) { + const text = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); + } } - const devices = await response.json(); + let devices; + try { + devices = await response.json(); + } catch (e) { + const text = await response.text(); + throw new Error(`Invalid JSON response: ${text}`); + } if (devices.length === 0) { resultsEl.textContent = '📡 No BLE devices found.\n\nTroubleshooting tips:\n- Make sure your printer is powered on\n- Ensure Bluetooth is enabled on this device\n- Bring the printer closer (within 5 meters)\n- Try restarting the printer'; diff --git a/pyproject.toml b/pyproject.toml index 7c779c7..b71aded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.33" +version = "0.1.34" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From 1243e1f8c5f99590a8fa7b9162c8247e7011d4e0 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 18:41:01 +0100 Subject: [PATCH 40/64] 0.1.35 --- fichero_printer/CHANGELOG.md | 11 +++++++++++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 23 ++++++++++------------- pyproject.toml | 2 +- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 33406b0..859fcdf 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -5,6 +5,17 @@ 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.35] - 2026-03-18 + +### Fixed +- **Web UI**: Fixed "body stream already read" error by reading response body only once. +- **Web UI**: Improved response handling to avoid double-reading response stream. +- **Web UI**: Better error messages for server-side 500 errors from scan endpoint. + +### Changed +- **Web UI**: Refactored response handling to use single response.read() call. +- **Web UI**: Enhanced error display for server-side scan failures. + ## [0.1.34] - 2026-03-18 ### Fixed diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index d6ca277..f915f67 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.34" +version: "0.1.35" 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/api.py b/fichero_printer/fichero/api.py index c55584c..df5a3c6 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -77,7 +77,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.34", + version = "0.1.35", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -155,24 +155,21 @@ def _ui_html() -> str: try { const response = await fetch('scan'); - if (!response.ok) { - try { - const error = await response.json(); - throw new Error(error.detail || `HTTP error! status: ${response.status}`); - } catch (e) { - const text = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); - } - } - - let devices; + let responseData; try { - devices = await response.json(); + responseData = await response.json(); } catch (e) { const text = await response.text(); throw new Error(`Invalid JSON response: ${text}`); } + if (!response.ok) { + const errorDetail = responseData.detail || `HTTP error! status: ${response.status}`; + throw new Error(errorDetail); + } + + const devices = responseData; + if (devices.length === 0) { resultsEl.textContent = '📡 No BLE devices found.\n\nTroubleshooting tips:\n- Make sure your printer is powered on\n- Ensure Bluetooth is enabled on this device\n- Bring the printer closer (within 5 meters)\n- Try restarting the printer'; } else { diff --git a/pyproject.toml b/pyproject.toml index b71aded..9ce0606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fichero-printer" -version = "0.1.34" +version = "0.1.35" description = "Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal label printer." readme = "README.md" requires-python = ">=3.10" From 72a82861e5424c1064a2db0a17c9adbc1ec9b259 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 18:41:14 +0100 Subject: [PATCH 41/64] new version --- fichero_printer/CHANGELOG.md | 9 ------ fichero_printer/fichero/api.py | 45 ++++++++++++++++++++++++++---- fichero_printer/fichero/index.html | 10 +++---- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 859fcdf..57af3fa 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -7,15 +7,6 @@ The format is based on Keep a Changelog and this project uses Semantic Versionin ## [0.1.35] - 2026-03-18 -### Fixed -- **Web UI**: Fixed "body stream already read" error by reading response body only once. -- **Web UI**: Improved response handling to avoid double-reading response stream. -- **Web UI**: Better error messages for server-side 500 errors from scan endpoint. - -### Changed -- **Web UI**: Refactored response handling to use single response.read() call. -- **Web UI**: Enhanced error display for server-side scan failures. - ## [0.1.34] - 2026-03-18 ### Fixed diff --git a/fichero_printer/fichero/api.py b/fichero_printer/fichero/api.py index df5a3c6..4d6d81f 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -47,6 +47,8 @@ from fichero.printer import ( # --------------------------------------------------------------------------- _DEFAULT_ADDRESS: str | None = os.environ.get("FICHERO_ADDR") +# Default to BLE transport (most reliable for Fichero/D11s printers) +# Set FICHERO_TRANSPORT=classic to force Classic Bluetooth (RFCOMM) _DEFAULT_CLASSIC: bool = os.environ.get("FICHERO_TRANSPORT", "").lower() == "classic" _DEFAULT_CHANNEL: int = int(os.environ.get("FICHERO_CHANNEL", "1")) @@ -255,11 +257,36 @@ async def get_status( 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 + detail = str(exc) + if "BLE" in detail or "BLE" in str(classic): + detail += "\n\nBLE Troubleshooting:\n" + detail += "- Ensure Home Assistant has Bluetooth permissions (host_dbus: true)\n" + detail += "- Make sure the printer is powered on and discoverable\n" + detail += "- Try restarting the printer\n" + detail += "- Check that no other device is connected to the printer" + raise HTTPException(status_code=404, detail=detail) from exc except PrinterTimeout as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc + detail = str(exc) + if not classic: # BLE timeout + detail += "\n\nBLE Connection Tips:\n" + detail += "- Bring the printer closer to the Home Assistant host\n" + detail += "- Ensure no Bluetooth interference (WiFi, USB 3.0, microwaves)\n" + detail += "- Try restarting the Bluetooth service on your host" + raise HTTPException(status_code=504, detail=detail) from exc except PrinterError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc + detail = str(exc) + error_str = str(exc).lower() + if not classic and ("br-connection-not-supported" in error_str or "dbus" in error_str): + detail += "\n\nBLE Permission Fix:\n" + detail += "1. Add 'host_dbus: true' to your add-on configuration\n" + detail += "2. Restart the add-on\n" + detail += "3. If using Classic Bluetooth, try: classic=true with channel=1" + elif not classic and "connection" in error_str: + detail += "\n\nBLE Connection Help:\n" + detail += "- Verify the BLE address is correct (not Classic Bluetooth address)\n" + detail += "- Ensure no other device is paired/connected to the printer\n" + detail += "- Try power cycling the printer" + raise HTTPException(status_code=502, detail=detail) from exc return { "ok": status.ok, @@ -320,15 +347,23 @@ async def scan_devices(): @app.post( "/pair", - summary="Pair and trust a Bluetooth device", + summary="Pair and trust a Classic Bluetooth device", status_code=200, + description="⚠️ ONLY for Classic Bluetooth (RFCOMM). BLE does not require pairing!", ) 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. + + ⚠️ IMPORTANT: This is ONLY for Classic Bluetooth (RFCOMM) connections. + BLE connections do NOT require pairing and will NOT work with this endpoint. + + For BLE issues, ensure: + - The printer is powered on and discoverable + - Home Assistant has proper Bluetooth permissions (host_dbus: true) + - You're using the correct BLE address (not Classic Bluetooth address) """ addr = _address(address) if not addr: diff --git a/fichero_printer/fichero/index.html b/fichero_printer/fichero/index.html index 7e011b6..eed4113 100644 --- a/fichero_printer/fichero/index.html +++ b/fichero_printer/fichero/index.html @@ -374,9 +374,9 @@
- + +
@@ -386,8 +386,8 @@
- - + +
From bdf07eb6610da2780700e6605be64a165007a7d2 Mon Sep 17 00:00:00 2001 From: paul2212 Date: Wed, 18 Mar 2026 19:01:08 +0100 Subject: [PATCH 42/64] v0.1.36: Add dark theme, fix BLE scanning, improve responsive design\n\n- Fixed BLE scanning RSSI error that caused 'BLEDevice' object has no attribute 'rssi'\n- Implemented complete dark theme with CSS variables and system preference support\n- Added responsive design with mobile-first breakpoints (768px, 1024px, 1440px)\n- Added theme toggle with local storage persistence\n- Improved BLE error handling and user guidance\n- Enhanced pairing function documentation to clarify BLE vs Classic Bluetooth\n- Updated version to 0.1.36 in config.yaml and api.py\n\nGenerated by Mistral Vibe.\nCo-Authored-By: Mistral Vibe --- fichero_printer/CHANGELOG.md | 24 +++ fichero_printer/config.yaml | 2 +- fichero_printer/fichero/api.py | 29 +++- fichero_printer/fichero/index.html | 245 ++++++++++++++++++++++------- 4 files changed, 234 insertions(+), 66 deletions(-) diff --git a/fichero_printer/CHANGELOG.md b/fichero_printer/CHANGELOG.md index 57af3fa..69b3023 100644 --- a/fichero_printer/CHANGELOG.md +++ b/fichero_printer/CHANGELOG.md @@ -5,6 +5,30 @@ 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.36] - 2026-03-19 + +### Fixed +- **BLE Scanning**: Fixed `'BLEDevice' object has no attribute 'rssi'` error by safely checking for RSSI attribute availability +- **Web UI**: Improved BLE scan error handling to gracefully handle missing RSSI data + +### Added +- **Dark Theme**: Complete dark theme implementation with CSS variables and system preference support +- **Responsive Design**: Mobile-first responsive layout with proper breakpoints (768px, 1024px, 1440px) +- **Theme Toggle**: Interactive theme switcher with local storage persistence +- **Touch Support**: Mobile-optimized button sizes and touch targets +- **System Dark Mode**: Automatic dark/light mode detection via `@media (prefers-color-scheme: dark)` + +### Changed +- **Web UI**: Modernized entire UI with dark theme colors, improved spacing, and better typography +- **Error Handling**: Enhanced BLE-specific error messages with troubleshooting guidance +- **Pairing Function**: Clarified that pairing is only for Classic Bluetooth, not BLE +- **HTML Structure**: Added responsive container system for better layout control + +### Improved +- **Accessibility**: Better ARIA labels, keyboard navigation, and color contrast +- **Performance**: Optimized CSS with variables and reduced redundancy +- **User Experience**: Clearer distinction between BLE and Classic Bluetooth workflows + ## [0.1.35] - 2026-03-18 ## [0.1.34] - 2026-03-18 diff --git a/fichero_printer/config.yaml b/fichero_printer/config.yaml index f915f67..6cf6e1a 100644 --- a/fichero_printer/config.yaml +++ b/fichero_printer/config.yaml @@ -1,5 +1,5 @@ name: "Fichero Printer" -version: "0.1.35" +version: "0.1.36" 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/api.py b/fichero_printer/fichero/api.py index 4d6d81f..7cb0ef7 100644 --- a/fichero_printer/fichero/api.py +++ b/fichero_printer/fichero/api.py @@ -79,7 +79,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.35", + version = "0.1.36", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -179,7 +179,15 @@ def _ui_html() -> str: devices.forEach((d, index) => { resultText += `${index + 1}. ${d.name || 'Unknown Device'}\n`; resultText += ` Address: ${d.address}\n`; - resultText += ` Signal: ${d.rssi} dBm (${Math.abs(d.rssi) < 60 ? 'Strong' : Math.abs(d.rssi) < 80 ? 'Good' : 'Weak'})\n`; + // Handle case where RSSI might not be available + if (d.rssi !== undefined) { + resultText += ` Signal: ${d.rssi} dBm (${Math.abs(d.rssi) < 60 ? 'Strong' : Math.abs(d.rssi) < 80 ? 'Good' : 'Weak'})\n`; + } else { + resultText += ` Signal: Not available\n`; + } + if (d.metadata) { + resultText += ` Metadata: ${d.metadata}\n`; + } resultText += ` ${'='.repeat(40)}\n`; }); resultText += '\n💡 Tip: Click on a device address above to use it for connection.'; @@ -334,10 +342,19 @@ async def scan_devices(): """Scan for nearby BLE devices for 10 seconds for debugging.""" try: devices = await BleakScanner.discover(timeout=10.0) - return [ - {"address": d.address, "name": d.name or "N/A", "rssi": d.rssi} - for d in devices - ] + result = [] + for d in devices: + device_info = { + "address": d.address, + "name": d.name or "N/A" + } + # RSSI may not be available on all platforms/versions + if hasattr(d, 'rssi'): + device_info["rssi"] = d.rssi + if hasattr(d, 'metadata'): + device_info["metadata"] = str(d.metadata) + result.append(device_info) + return result except Exception as exc: # This provides more debug info to the user if scanning fails raise HTTPException( diff --git a/fichero_printer/fichero/index.html b/fichero_printer/fichero/index.html index eed4113..1dd09ca 100644 --- a/fichero_printer/fichero/index.html +++ b/fichero_printer/fichero/index.html @@ -6,19 +6,42 @@ Fichero Printer