31 Commits

Author SHA1 Message Date
paul2212
48c40d9f8f 0.1.27 2026-03-16 14:17:49 +01:00
a7fabdae54 Merge pull request '0.1.25' (#6) from 0.1.25 into main
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Reviewed-on: #6
2026-03-16 12:48:37 +00:00
paul2212
fef3d18d3f 0.1.25 2026-03-16 13:48:12 +01:00
66c3f06b48 Merge pull request 'refactor' (#5) from 0.1.23 into main
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Reviewed-on: #5
2026-03-16 11:44:45 +00:00
paul2212
7843a38407 refactor 2026-03-16 12:43:26 +01:00
eee58431ab update version
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-16 09:26:06 +00:00
40a1f78f55 Merge pull request 'release/0.1.20' (#4) from release/0.1.20 into main
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Reviewed-on: #4
2026-03-16 09:21:35 +00:00
paul2212
16886bfa21 add files 2026-03-16 10:16:12 +01:00
paul2212
8520a88197 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.
2026-03-16 10:15:22 +01:00
paul2212
1a51ebb122 Retry BLE with fresh LE scan on br-connection-not-supported (0.1.15)
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 22:56:13 +01:00
paul2212
92a7224774 Avoid raw MAC BLE fallback and bump to 0.1.14
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 22:50:57 +01:00
9f191b564a Bump version to 0.1.13 in API and package.json
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:19:03 +01:00
paul2212
42e56e1b9f Retry BLE service-discovery disconnect errors and bump to 0.1.13
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:12:56 +01:00
7778a6f614 Merge branch 'main' of https://git.leuschner.dev/Tobias/Fichero
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:11:06 +01:00
9d77fbe366 Enhance UI layout and styles for LabelDesigner and MainPage components 2026-03-07 15:11:03 +01:00
paul2212
6b6d57bd77 Prefer BLE device object resolution and bump to 0.1.12
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:07:53 +01:00
paul2212
822dbd35b2 Handle BLE connect TimeoutError and bump to 0.1.11
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:40:12 +01:00
45d945a9d4 Merge branch 'main' of https://git.leuschner.dev/Tobias/Fichero
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:30:06 +01:00
7317a60818 Bump version to 0.1.9 in API and package.json 2026-03-07 14:30:03 +01:00
paul2212
8513afe831 Add BLE retry/backoff for connection timeouts and bump to 0.1.10
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:29:44 +01:00
4dd04d1d34 Add printer scanning functionality and enhance UI for address input 2026-03-07 14:29:32 +01:00
paul2212
54ba6795c0 Add add-on changelog and improve classic RFCOMM fallback (0.1.9)
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:21:36 +01:00
paul2212
081883c823 Add HA print web UI and release version 0.1.8
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:13:44 +01:00
paul2212
8c00001d68 Add NET_RAW for RFCOMM and bump add-on version to 0.1.5
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:04:50 +01:00
paul2212
aa125736f3 Fix RFCOMM connect under uvloop and bump version to 0.1.4
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:00:09 +01:00
cb471b1cc8 Merge pull request 'Bump add-on and package version to 0.1.3' (#3) from Paul/Fichero:main into main
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Reviewed-on: #3
2026-03-07 12:55:07 +00:00
paul2212
3356909982 Bump add-on and package version to 0.1.3 2026-03-07 13:54:04 +01:00
440b1c278a Merge pull request 'fix error 500' (#2) from Paul/Fichero:main into main
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Reviewed-on: #2
2026-03-07 12:52:15 +00:00
paul2212
99c2fb79d7 fix error 500 2026-03-07 13:51:17 +01:00
265cded661 Merge pull request 'add features' (#1) from Paul/Fichero:main into main
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Reviewed-on: #1
2026-03-07 12:40:44 +00:00
paul2212
09f340c6e9 add features 2026-03-07 13:39:00 +01:00
22 changed files with 1221 additions and 871 deletions

156
CHANGELOG.md Normal file
View File

@@ -0,0 +1,156 @@
# 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.

View File

@@ -2,6 +2,16 @@
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.
## 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.
@@ -130,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 <MAC>` and `bluetoothctl trust <MAC>` (or `bluetoothctl remove <MAC>`) 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.

View File

@@ -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",
]

View File

@@ -5,6 +5,7 @@ Start with:
or:
python -m fichero.api
Endpoints:
GET /status Printer status
GET /info Printer info (model, firmware, battery, …)
@@ -17,12 +18,16 @@ 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
@@ -70,8 +75,10 @@ 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.27",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
)
app.add_middleware(
@@ -87,10 +94,43 @@ 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"
try:
template_path = Path(__file__).parent / "index.html"
template = template_path.read_text(encoding="utf-8")
except FileNotFoundError:
return "<h1>Error: index.html not found</h1>"
# 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",
@@ -150,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",

View File

@@ -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 <data>
yl = rows & 0xFF
yh = (rows >> 8) & 0xFF
header = bytes([0x1D, 0x76, 0x30, 0x00, BYTES_PER_ROW, 0x00, yl, yh])
await pc.send_chunked(header + raster)
await asyncio.sleep(DELAY_RASTER_SETTLE)
await pc.form_feed()
await asyncio.sleep(DELAY_AFTER_FEED)
ok = await pc.stop_print()
if not ok:
print(" WARNING: no OK/0xAA from stop command")
return True
async def cmd_info(args: argparse.Namespace) -> None:
async with connect(args.address, 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()

View File

@@ -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

View File

@@ -1,380 +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
# --- 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

View File

@@ -0,0 +1,90 @@
# 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.
## 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.
## 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.
## 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.
## 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.
## 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.
- 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.

View File

@@ -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
@@ -17,12 +22,16 @@ 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
Nach dem Start ist die API unter `http://<HA-IP>:<port>` erreichbar.
Die interaktive Swagger-Dokumentation ist unter `http://<HA-IP>:<port>/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. Dort erscheint direkt das Webinterface zum Abrufen von Status/Info sowie zum Drucken von Text und Bildern.
2. Direkt per Port im Netzwerk: `http://<HA-IP>:<port>` (z.B. `http://homeassistant.local:8765`).
Hinweis: Die API-Dokumentation bleibt unter `/docs` erreichbar.
### Endpunkte
@@ -137,7 +146,10 @@ 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`).
- 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.

View File

@@ -1,28 +1,32 @@
ARG BUILD_FROM
FROM $BUILD_FROM
# Only dbus-dev needed to talk to the HOST BlueZ via D-Bus (host_dbus: true).
# 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 \
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)
# 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>=0.21" \
"fastapi>=0.111" \
"uvicorn[standard]>=0.29" \
"bleak" \
"numpy" \
"Pillow" \
"fastapi" \
"uvicorn[standard]" \
"python-multipart>=0.0.9"
# Copy the fichero Python package into the container
# Copy the application code into the container.
WORKDIR /app
COPY fichero/ /app/fichero/
# Make the package importable without installation
# Make the 'fichero' package importable.
ENV PYTHONPATH=/app
# Copy startup script and normalise line endings (Windows CRLF -> LF)

View File

@@ -1,5 +1,5 @@
name: "Fichero Printer"
version: "0.1.2"
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"
@@ -14,6 +14,12 @@ 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]/"
full_access: true
host_network: true
host_dbus: true
@@ -22,6 +28,7 @@ host_dbus: true
# BLE uses D-Bus (host_dbus) and does not need this.
privileged:
- NET_ADMIN
- NET_RAW
options:
port: 8765

View File

@@ -5,6 +5,7 @@ Start with:
or:
python -m fichero.api
Endpoints:
GET /status Printer status
GET /info Printer info (model, firmware, battery, …)
@@ -17,12 +18,16 @@ 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
@@ -70,8 +75,10 @@ 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.26",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
)
app.add_middleware(
@@ -87,10 +94,43 @@ 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"
try:
template_path = Path(__file__).parent / "index.html"
template = template_path.read_text(encoding="utf-8")
except FileNotFoundError:
return "<h1>Error: index.html not found</h1>"
# 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",
@@ -150,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",

View File

@@ -0,0 +1,307 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fichero Printer</title>
<style>
:root {
--bg: #f4efe6;
--panel: #fffaf2;
--line: #d8cdbd;
--ink: #2d241d;
--muted: #6c6258;
--accent: #b55e33;
--accent-2: #245b4b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Noto Sans", 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%);
}
main {
max-width: 980px;
margin: 0 auto;
padding: 24px 16px 40px;
}
.hero {
margin-bottom: 20px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 250, 242, 0.92);
backdrop-filter: blur(4px);
}
h1, h2 { margin: 0 0 12px; }
.muted { color: var(--muted); }
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.card {
padding: 18px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06);
}
label {
display: block;
margin: 10px 0 6px;
font-size: 0.92rem;
font-weight: 600;
}
input, select, textarea, button {
width: 100%;
border-radius: 10px;
border: 1px solid var(--line);
padding: 10px 12px;
font: inherit;
}
textarea { min-height: 110px; resize: vertical; }
.row {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inline {
display: flex;
gap: 10px;
align-items: center;
margin-top: 12px;
}
.inline input[type="checkbox"] { width: auto; }
button {
cursor: pointer;
font-weight: 700;
color: #fff;
background: var(--accent);
border: none;
}
button.alt { background: var(--accent-2); }
pre {
overflow: auto;
margin: 0;
padding: 12px;
border-radius: 12px;
background: #241f1a;
color: #f7efe4;
min-height: 140px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
.actions button {
width: auto;
min-width: 140px;
}
@media (max-width: 640px) {
.row { grid-template-columns: 1fr; }
.actions button { width: 100%; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>Fichero Printer</h1>
<p class="muted">Home Assistant print console for status, text labels, and image uploads.</p>
<p class="muted">API docs remain available at <a href="docs">/docs</a>.</p>
</section>
<section class="grid">
<div class="card">
<h2>Connection</h2>
<label for="address">Printer address</label>
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0">
<div class="row">
<div>
<label for="transport">Transport</label>
<select id="transport">
<option value="ble"{ble_selected}>BLE</option>
<option value="classic"{classic_selected}>Classic</option>
</select>
</div>
<div>
<label for="channel">RFCOMM channel</label>
<input id="channel" type="number" min="1" max="30" value="{default_channel}">
</div>
</div>
<div class="actions">
<button type="button" class="alt" onclick="runPost('pair')">Pair Device</button>
<button type="button" class="alt" onclick="runPost('unpair')">Unpair Device</button>
<button type="button" class="alt" onclick="runGet('status')">Get Status</button>
<button type="button" class="alt" onclick="runGet('info')">Get Info</button>
</div>
</div>
<div class="card">
<h2>Output</h2>
<pre id="output">Ready.</pre>
</div>
<div class="card">
<h2>Print Text</h2>
<label for="text">Text</label>
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
<div class="row">
<div>
<label for="text_density">Density</label>
<select id="text_density">
<option value="0">0</option>
<option value="1">1</option>
<option value="2" selected>2</option>
</select>
</div>
<div>
<label for="text_copies">Copies</label>
<input id="text_copies" type="number" min="1" max="99" value="1">
</div>
</div>
<div class="row">
<div>
<label for="text_font_size">Font size</label>
<input id="text_font_size" type="number" min="6" max="200" value="30">
</div>
<div>
<label for="text_label_length">Label length (mm)</label>
<input id="text_label_length" type="number" min="5" max="500" value="30">
</div>
</div>
<label for="text_paper">Paper</label>
<select id="text_paper">
<option value="gap" selected>gap</option>
<option value="black">black</option>
<option value="continuous">continuous</option>
</select>
<div class="actions">
<button type="button" onclick="printText()">Print Text</button>
</div>
</div>
<div class="card">
<h2>Print Image</h2>
<label for="image_file">Image file</label>
<input id="image_file" type="file" accept="image/*">
<div class="row">
<div>
<label for="image_density">Density</label>
<select id="image_density">
<option value="0">0</option>
<option value="1">1</option>
<option value="2" selected>2</option>
</select>
</div>
<div>
<label for="image_copies">Copies</label>
<input id="image_copies" type="number" min="1" max="99" value="1">
</div>
</div>
<div class="row">
<div>
<label for="image_label_length">Label length (mm)</label>
<input id="image_label_length" type="number" min="5" max="500" value="30">
</div>
<div class="inline">
<input id="image_dither" type="checkbox" checked>
<label for="image_dither">Enable dithering</label>
</div>
</div>
<label for="image_paper">Paper</label>
<select id="image_paper">
<option value="gap" selected>gap</option>
<option value="black">black</option>
<option value="continuous">continuous</option>
</select>
<div class="actions">
<button type="button" onclick="printImage()">Print Image</button>
</div>
</div>
</section>
</main>
<script>
function commonParams() {
const address = document.getElementById("address").value.trim();
const classic = document.getElementById("transport").value === "classic";
const channel = document.getElementById("channel").value;
const params = new URLSearchParams();
if (address) params.set("address", address);
params.set("classic", String(classic));
params.set("channel", channel);
return params;
}
async function showResponse(response) {
const output = document.getElementById("output");
let data;
try {
data = await response.json();
} catch {
data = { detail: await response.text() };
}
output.textContent = JSON.stringify({ status: response.status, ok: response.ok, data }, null, 2);
}
async function runGet(path) {
const response = await fetch(`${path}?${commonParams().toString()}`);
await showResponse(response);
}
async function runPost(path) {
const form = new FormData();
const params = commonParams();
for (const [key, value] of params.entries()) {
form.set(key, value);
}
const response = await fetch(path, { method: "POST", body: form });
await showResponse(response);
}
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);
const response = await fetch("print/text", { method: "POST", body: form });
await showResponse(response);
}
async function printImage() {
const fileInput = document.getElementById("image_file");
if (!fileInput.files.length) {
document.getElementById("output").textContent = "Select an image file first.";
return;
}
const form = new FormData();
form.set("file", fileInput.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);
const response = await fetch("print/image", { method: "POST", body: form });
await showResponse(response);
}
</script>
</body>
</html>

View File

@@ -8,10 +8,12 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
import asyncio
import sys
import errno
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+) ---
@@ -52,6 +54,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 ---
@@ -87,6 +91,35 @@ 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)
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 ---
@@ -154,13 +187,28 @@ 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(
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
@@ -368,13 +416,99 @@ 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
else:
addr = address or await find_printer()
async with BleakClient(addr) 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:
# 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 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()
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.")

View File

@@ -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."

View File

@@ -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'."

View File

@@ -1,28 +1,31 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "fichero-printer"
version = "0.1.1"
description = "Fichero D11s thermal label printer - BLE CLI tool"
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"
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 = ["fichero_printer"]
include = ["fichero*"]

0
sync_addon.sh Normal file
View File

View File

@@ -2,7 +2,7 @@
"name": "fichero-web",
"private": true,
"type": "module",
"version": "0.0.1",
"version": "0.1.13",
"scripts": {
"dev": "vite",
"build": "vite build",

View File

@@ -436,17 +436,19 @@
<svelte:window bind:innerWidth={windowWidth} onkeydown={onKeyDown} onpaste={onPaste} />
<div class="image-editor">
<div class="row mb-3">
<div class="row mb-4">
<div class="col d-flex {windowWidth === 0 || labelProps.size.width < windowWidth ? 'justify-content-center' : ''}">
<div class="canvas-wrapper print-start-{labelProps.printDirection}">
<canvas bind:this={htmlCanvas}></canvas>
<div class="canvas-panel">
<div class="canvas-wrapper print-start-{labelProps.printDirection}">
<canvas bind:this={htmlCanvas}></canvas>
</div>
</div>
</div>
</div>
<div class="row mb-1">
<div class="row mb-2">
<div class="col d-flex justify-content-center">
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
<div class="toolbar toolbar-bar d-flex flex-wrap gap-1 justify-content-center align-items-center">
<LabelPropsEditor {labelProps} onChange={onUpdateLabelProps} />
<button class="btn btn-sm btn-secondary" onclick={clearCanvas} title={$tr("editor.clear")}>
@@ -493,9 +495,10 @@
</div>
</div>
<div class="row mb-1">
{#if selectedCount > 0 || selectedObject}
<div class="row mb-2">
<div class="col d-flex justify-content-center">
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
<div class="toolbar toolbar-bar d-flex flex-wrap gap-1 justify-content-center align-items-center">
{#if selectedCount > 0}
<button class="btn btn-sm btn-danger me-1" onclick={deleteSelected} title={$tr("editor.delete")}>
<MdIcon icon="delete" />
@@ -534,6 +537,7 @@
</div>
</div>
</div>
{/if}
{#if previewOpened}
<PrintPreview
@@ -548,16 +552,16 @@
<style>
.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;
}
</style>

View File

@@ -14,78 +14,79 @@
let debugStuffShow = $state<boolean>(false);
</script>
<div class="container my-2">
<div class="row align-items-center mb-3">
<div class="col">
<h1 class="title">
<img src="{import.meta.env.BASE_URL}logo.png" alt="Fichero" class="logo" />
</h1>
</div>
<div class="col-md-3">
<PrinterConnector />
</div>
</div>
<div class="row">
<div class="col">
<BrowserWarning />
</div>
</div>
<header class="app-header">
<div class="container-fluid px-3">
<div class="d-flex align-items-center gap-2">
<div class="row">
<div class="col">
<LabelDesigner />
</div>
</div>
</div>
<div class="footer text-end text-secondary p-3">
<div>
<select class="form-select form-select-sm text-secondary d-inline-block w-auto" bind:value={$locale}>
{#each Object.entries(locales) as [key, name] (key)}
<option value={key}>{name}</option>
{/each}
</select>
</div>
<div>
{#if appCommit}
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer/commit/{appCommit}">
{appCommit.slice(0, 6)}
<a class="app-brand" href=".">
<img src="{import.meta.env.BASE_URL}logo.png" alt="Fichero" class="app-brand-logo" />
<span class="app-brand-name d-none d-sm-inline">Fichero<em>Printer</em></span>
</a>
{/if}
{$tr("main.built")}
{buildDate}
</div>
<div>
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer">{$tr("main.code")}</a>
<button class="text-secondary btn btn-link p-0" onclick={() => debugStuffShow = true}>
<MdIcon icon="bug_report" />
</button>
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
<select
class="form-select form-select-sm lang-select"
bind:value={$locale}>
{#each Object.entries(locales) as [key, name] (key)}
<option value={key}>{name}</option>
{/each}
</select>
<PrinterConnector />
<button
class="btn btn-sm btn-secondary"
onclick={() => (debugStuffShow = true)}
title="Debug">
<MdIcon icon="bug_report" />
</button>
</div>
</div>
</div>
</header>
<div class="container-fluid px-3 mt-3">
<BrowserWarning />
<LabelDesigner />
</div>
<footer class="text-secondary text-end p-3 footer-meta">
{#if appCommit}
<a
class="text-secondary"
href="https://github.com/mohamedha/fichero-printer/commit/{appCommit}">
{appCommit.slice(0, 6)}
</a>
·
{/if}
{$tr("main.built")} {buildDate} ·
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer">{$tr("main.code")}</a>
</footer>
{#if debugStuffShow}
<DebugStuff bind:show={debugStuffShow} />
{/if}
<style>
.logo {
height: 1.4em;
vertical-align: middle;
margin-right: 0.2em;
border-radius: 4px;
.lang-select {
width: auto;
min-width: 65px;
font-size: 0.8rem;
}
.footer {
.footer-meta {
position: absolute;
bottom: 0;
right: 0;
font-size: 0.72rem;
z-index: -1;
}
@media only screen and (max-device-width: 540px) {
.footer {
position: relative !important;
z-index: 0 !important;
.footer-meta {
position: relative;
z-index: 0;
}
}
</style>

View File

@@ -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);
}