20 Commits

Author SHA1 Message Date
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
14 changed files with 1399 additions and 98 deletions

73
CHANGELOG.md Normal file
View File

@@ -0,0 +1,73 @@
# 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.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. 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/) 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. 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.

View File

@@ -23,6 +23,8 @@ from typing import Annotated
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware 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 PIL import Image
from fichero.cli import DOTS_PER_MM, do_print from fichero.cli import DOTS_PER_MM, do_print
@@ -70,8 +72,10 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI( app = FastAPI(
title="Fichero Printer API", title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.", description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.0", version="0.1.13",
lifespan=lifespan, lifespan=lifespan,
docs_url=None,
redoc_url=None,
) )
app.add_middleware( app.add_middleware(
@@ -87,10 +91,324 @@ def _address(address: str | None) -> str | None:
return address or _DEFAULT_ADDRESS return address or _DEFAULT_ADDRESS
def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
return f"""<!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"{" selected" if default_transport == "ble" else ""}>BLE</option>
<option value="classic"{" selected" if default_transport == "classic" else ""}>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="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 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>"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Endpoints # 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( @app.get(
"/status", "/status",

View File

@@ -12,6 +12,7 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
from bleak.exc import BleakDBusError, BleakError
# --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) --- # --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) ---
@@ -52,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_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments 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 --- # --- Exceptions ---
@@ -87,6 +90,23 @@ async def find_printer() -> str:
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") 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 --- # --- Status ---
@@ -154,13 +174,28 @@ class RFCOMMClient:
sock = _socket.socket( sock = _socket.socket(
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM _socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
) )
sock.setblocking(False)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
await asyncio.wait_for( # uvloop's sock_connect path goes through getaddrinfo and doesn't
loop.sock_connect(sock, (self._address, self._channel)), # support AF_BLUETOOTH addresses reliably. Use direct socket connect
timeout=10.0, # 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: except Exception:
sock.close() sock.close()
raise raise
@@ -368,13 +403,74 @@ async def connect(
if classic: if classic:
if not address: if not address:
raise PrinterError("--address is required for Classic Bluetooth (no scanning)") raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
async with RFCOMMClient(address, channel) as client: # 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) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc 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: else:
addr = address or await find_printer() target = await resolve_ble_target(address)
async with BleakClient(addr) as client: 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
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try:
async with BleakClient(target) as client:
pc = PrinterClient(client) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc 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:
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.")

View File

@@ -0,0 +1,47 @@
# 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.
## 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 Thermodrucker. Das Add-on ermöglicht das Drucken von Textetiketten und Bildern
direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen. 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 ## Voraussetzungen
- Fichero D11s / AiYin D11s Drucker - 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) | | `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. | | `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) | | `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 ## Verwendung
Nach dem Start ist die API unter `http://<HA-IP>:<port>` erreichbar. Das Add-on ist nach dem Start auf zwei Arten erreichbar:
Die interaktive Swagger-Dokumentation ist unter `http://<HA-IP>:<port>/docs` verfügbar.
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 ### Endpunkte
@@ -137,7 +146,10 @@ rest_command:
- **BLE (Standard):** Das Add-on benötigt Zugriff auf BlueZ über D-Bus - **BLE (Standard):** Das Add-on benötigt Zugriff auf BlueZ über D-Bus
(`host_dbus: true`). Home Assistant OS stellt BlueZ automatisch bereit. (`host_dbus: true`). Home Assistant OS stellt BlueZ automatisch bereit.
- **Classic Bluetooth (RFCOMM):** Nur unter Linux verfügbar. Erfordert die - **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 - Wenn die BLE-Adresse bekannt ist, diese in der Konfiguration eintragen
das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig). das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig).
- Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird. - Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird.

View File

@@ -1,5 +1,5 @@
name: "Fichero Printer" name: "Fichero Printer"
version: "0.1.2" version: "0.1.13"
slug: "fichero_printer" slug: "fichero_printer"
description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth" description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth"
url: "https://git.leuschner.dev/Tobias/Fichero" url: "https://git.leuschner.dev/Tobias/Fichero"
@@ -14,6 +14,12 @@ arch:
init: false init: false
startup: application startup: application
boot: auto 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_network: true
host_dbus: true host_dbus: true
@@ -22,6 +28,7 @@ host_dbus: true
# BLE uses D-Bus (host_dbus) and does not need this. # BLE uses D-Bus (host_dbus) and does not need this.
privileged: privileged:
- NET_ADMIN - NET_ADMIN
- NET_RAW
options: options:
port: 8765 port: 8765

View File

@@ -23,6 +23,8 @@ from typing import Annotated
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware 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 PIL import Image
from fichero.cli import DOTS_PER_MM, do_print from fichero.cli import DOTS_PER_MM, do_print
@@ -33,6 +35,7 @@ from fichero.printer import (
PrinterNotFound, PrinterNotFound,
PrinterTimeout, PrinterTimeout,
connect, connect,
find_printer,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -70,8 +73,10 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI( app = FastAPI(
title="Fichero Printer API", title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.", description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.0", version="0.1.13",
lifespan=lifespan, lifespan=lifespan,
docs_url=None,
redoc_url=None,
) )
app.add_middleware( app.add_middleware(
@@ -87,10 +92,540 @@ def _address(address: str | None) -> str | None:
return address or _DEFAULT_ADDRESS return address or _DEFAULT_ADDRESS
def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
return f"""<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fichero Printer</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
:root {{
--s0: #161819;
--s1: #1c1e20;
--s2: #232628;
--s3: #2a2d30;
--ink: #e4e7eb;
--muted: #949ba4;
--dim: #5d6670;
--brand: #07addf;
--brand-h: #0699c7;
--brand-glow: rgba(7,173,223,.18);
--ok: #6bb88a;
--warn: #d4a24c;
--err: #d45454;
--border: rgba(228,231,235,.08);
--border-e: rgba(228,231,235,.14);
--ctrl-bg: #141617;
--ctrl-border: rgba(228,231,235,.10);
--r: 10px;
--r-lg: 14px;
}}
html {{ height: 100%; }}
body {{
min-height: 100dvh;
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--ink);
background-color: var(--s0);
background-image:
radial-gradient(ellipse at 10% 90%, rgba(7,173,223,.07) 0%, transparent 55%),
radial-gradient(ellipse at 90% 5%, rgba(7,173,223,.04) 0%, transparent 55%);
background-attachment: fixed;
}}
/* ── Header ─────────────────────────────────────── */
header {{
position: sticky;
top: 0;
z-index: 100;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 12px;
background: rgba(22,24,25,.82);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border);
}}
.brand {{ font-size: 1.05rem; font-weight: 700; letter-spacing: -.025em; }}
.brand em {{ color: var(--brand); font-style: normal; }}
.header-right {{ margin-left: auto; display: flex; align-items: center; gap: 8px; }}
.status-pill {{
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
font-size: .78rem;
font-weight: 600;
background: var(--s2);
border: 1px solid var(--border-e);
color: var(--muted);
transition: .25s;
cursor: default;
white-space: nowrap;
}}
.status-pill .dot {{
width: 7px; height: 7px;
border-radius: 50%;
background: var(--dim);
transition: .25s;
}}
.status-pill.ok {{ color: var(--ok); border-color: rgba(107,184,138,.3); }}
.status-pill.ok .dot {{ background: var(--ok); box-shadow: 0 0 6px var(--ok); }}
.status-pill.err {{ color: var(--err); border-color: rgba(212,84,84,.3); }}
.status-pill.err .dot {{ background: var(--err); box-shadow: 0 0 6px var(--err); }}
/* ── Layout ─────────────────────────────────────── */
main {{
max-width: 1000px;
margin: 0 auto;
padding: 24px 16px 60px;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}}
main > .full {{ grid-column: 1 / -1; }}
/* ── Cards ──────────────────────────────────────── */
.card {{
padding: 20px;
background: var(--s1);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: 0 8px 32px rgba(0,0,0,.3);
}}
.card-title {{
font-size: .7rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 14px;
}}
/* ── Form ───────────────────────────────────────── */
label {{
display: block;
font-size: .8rem;
font-weight: 600;
color: var(--muted);
margin: 12px 0 5px;
}}
label:first-child {{ margin-top: 0; }}
input, select, textarea {{
width: 100%;
padding: 8px 11px;
border-radius: var(--r);
border: 1px solid var(--ctrl-border);
background: var(--ctrl-bg);
color: var(--ink);
font: inherit;
transition: border-color .15s, box-shadow .15s;
outline: none;
}}
input:focus, select:focus, textarea:focus {{
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(7,173,223,.2);
}}
textarea {{ min-height: 90px; resize: vertical; }}
.two-col {{ display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }}
.check-row {{ display: flex; align-items: center; gap: 8px; margin-top: 14px; }}
.check-row input[type=checkbox] {{ width: 15px; height: 15px; accent-color: var(--brand); }}
.check-row label {{ margin: 0; font-size: .85rem; color: var(--ink); }}
/* ── Buttons ────────────────────────────────────── */
.btn {{
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--r);
border: none;
font: inherit;
font-size: .85rem;
font-weight: 600;
cursor: pointer;
transition: background .15s, box-shadow .15s, opacity .15s;
}}
.btn:disabled {{ opacity: .5; cursor: not-allowed; }}
.btn-primary {{
background: var(--brand);
color: #fff;
}}
.btn-primary:not(:disabled):hover {{
background: var(--brand-h);
box-shadow: 0 0 0 3px var(--brand-glow);
}}
.btn-secondary {{
background: var(--s3);
color: var(--ink);
border: 1px solid var(--border-e);
}}
.btn-secondary:not(:disabled):hover {{
background: var(--s3);
border-color: var(--brand);
}}
.btn-group {{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }}
.btn-group .btn {{ flex: 1; min-width: 110px; }}
/* ── Address row ────────────────────────────────── */
.addr-row {{ display: flex; gap: 8px; }}
.addr-row input {{ flex: 1; min-width: 0; }}
.addr-row .btn {{ padding: 8px 12px; white-space: nowrap; flex-shrink: 0; }}
/* ── Output / pre ───────────────────────────────── */
pre {{
background: var(--s0);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--muted);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
font-size: .78rem;
line-height: 1.6;
padding: 14px;
min-height: 130px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}}
pre.ok {{ color: var(--ok); border-color: rgba(107,184,138,.2); }}
pre.err {{ color: var(--err); border-color: rgba(212,84,84,.2); }}
/* ── Spinner ────────────────────────────────────── */
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.spinner {{
display: inline-block;
width: 13px; height: 13px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .65s linear infinite;
}}
/* ── Footer ─────────────────────────────────────── */
footer {{
text-align: center;
padding: 16px;
font-size: .72rem;
color: var(--dim);
}}
footer a {{ color: var(--dim); text-decoration: none; }}
footer a:hover {{ color: var(--brand); }}
@media (max-width: 520px) {{
.two-col {{ grid-template-columns: 1fr; }}
.btn-group .btn {{ min-width: 0; }}
}}
</style>
</head>
<body>
<header>
<span class="brand">Fichero<em>Printer</em></span>
<div class="header-right">
<span class="status-pill" id="status-pill">
<span class="dot"></span>
<span id="status-text">Unknown</span>
</span>
<button class="btn btn-secondary" style="padding:6px 10px;font-size:.78rem" onclick="refreshStatus()">↻</button>
</div>
</header>
<main>
<!-- Connection card -->
<div class="card">
<p class="card-title">Connection</p>
<label for="address">Bluetooth address</label>
<div class="addr-row">
<input id="address" value="{default_address}" placeholder="AA:BB:CC:DD:EE:FF">
<button class="btn btn-secondary" id="scan-btn" onclick="scanAddress()">
<span id="scan-icon">⟳</span> Scan
</button>
</div>
<div class="two-col">
<div>
<label for="transport">Transport</label>
<select id="transport">
<option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option>
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic BT</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="btn-group">
<button class="btn btn-secondary" onclick="runGet('status')">Status</button>
<button class="btn btn-secondary" onclick="runGet('info')">Device info</button>
</div>
</div>
<!-- Output card -->
<div class="card">
<p class="card-title">Response</p>
<pre id="output">Waiting for a command…</pre>
</div>
<!-- Print text card -->
<div class="card">
<p class="card-title">Print text</p>
<label for="text">Text</label>
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
<div class="two-col">
<div>
<label for="text_density">Density</label>
<select id="text_density">
<option value="0">0 Light</option>
<option value="1">1 Medium</option>
<option value="2" selected>2 Dark</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="two-col">
<div>
<label for="text_font_size">Font size (pt)</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 type</label>
<select id="text_paper">
<option value="gap" selected>Gap / label</option>
<option value="black">Black mark</option>
<option value="continuous">Continuous</option>
</select>
<div class="btn-group">
<button class="btn btn-primary" onclick="printText()">🖨 Print text</button>
</div>
</div>
<!-- Print image card -->
<div class="card">
<p class="card-title">Print image</p>
<label for="image_file">Image file (PNG / JPEG / BMP / WEBP)</label>
<input id="image_file" type="file" accept="image/*">
<div class="two-col">
<div>
<label for="image_density">Density</label>
<select id="image_density">
<option value="0">0 Light</option>
<option value="1">1 Medium</option>
<option value="2" selected>2 Dark</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="two-col">
<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>
<label for="image_paper">Paper type</label>
<select id="image_paper">
<option value="gap" selected>Gap / label</option>
<option value="black">Black mark</option>
<option value="continuous">Continuous</option>
</select>
</div>
</div>
<div class="check-row">
<input id="image_dither" type="checkbox" checked>
<label for="image_dither">Floyd-Steinberg dithering</label>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="printImage()">🖨 Print image</button>
</div>
</div>
</main>
<footer>
Fichero Printer REST API &nbsp;·&nbsp;
<a href="docs">Swagger UI</a>
</footer>
<script>
// ── Helpers ──────────────────────────────────────────
function commonParams() {{
const address = document.getElementById("address").value.trim();
const classic = document.getElementById("transport").value === "classic";
const channel = document.getElementById("channel").value;
const p = new URLSearchParams();
if (address) p.set("address", address);
p.set("classic", String(classic));
p.set("channel", channel);
return p;
}}
function setOutput(obj, isOk) {{
const pre = document.getElementById("output");
pre.textContent = JSON.stringify(obj, null, 2);
pre.className = isOk ? "ok" : "err";
}}
async function showResponse(response) {{
let data;
try {{ data = await response.json(); }} catch {{ data = {{ detail: await response.text() }}; }}
setOutput({{ status: response.status, ok: response.ok, data }}, response.ok);
}}
// ── Status pill ──────────────────────────────────────
async function refreshStatus() {{
try {{
const r = await fetch("status?" + commonParams().toString());
const d = await r.json();
const pill = document.getElementById("status-pill");
const txt = document.getElementById("status-text");
if (r.ok && d.ok) {{
pill.className = "status-pill ok";
txt.textContent = d.no_paper ? "No paper" : d.charging ? "Charging" : "Ready";
}} else {{
pill.className = "status-pill err";
txt.textContent = r.ok ? "Not ready" : "Not found";
}}
}} catch {{ /* ignore */ }}
}}
// auto-refresh status on load
window.addEventListener("DOMContentLoaded", refreshStatus);
// ── Scan ─────────────────────────────────────────────
async function scanAddress() {{
const btn = document.getElementById("scan-btn");
const icon = document.getElementById("scan-icon");
btn.disabled = true;
icon.outerHTML = '<span id="scan-icon" class="spinner"></span>';
setOutput({{ message: "Scanning for printer (up to 8 s)…" }}, true);
try {{
const r = await fetch("scan");
const d = await r.json();
if (r.ok && d.address) {{
document.getElementById("address").value = d.address;
}}
setOutput({{ status: r.status, ok: r.ok, data: d }}, r.ok);
}} catch (e) {{
setOutput({{ error: String(e) }}, false);
}} finally {{
btn.disabled = false;
document.getElementById("scan-icon").outerHTML = '<span id="scan-icon">⟳</span>';
}}
}}
async function runGet(path) {{
setOutput({{ message: "Loading…" }}, true);
const r = await fetch(path + "?" + commonParams().toString());
await showResponse(r);
}}
// ── 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();
}}
</script>
</body>
</html>"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Endpoints # 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( @app.get(
"/status", "/status",

View File

@@ -12,6 +12,7 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
from bleak.exc import BleakDBusError, BleakError
# --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) --- # --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) ---
@@ -52,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_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments 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 --- # --- Exceptions ---
@@ -87,6 +90,23 @@ async def find_printer() -> str:
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") 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 --- # --- Status ---
@@ -154,13 +174,28 @@ class RFCOMMClient:
sock = _socket.socket( sock = _socket.socket(
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM _socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
) )
sock.setblocking(False)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
await asyncio.wait_for( # uvloop's sock_connect path goes through getaddrinfo and doesn't
loop.sock_connect(sock, (self._address, self._channel)), # support AF_BLUETOOTH addresses reliably. Use direct socket connect
timeout=10.0, # 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: except Exception:
sock.close() sock.close()
raise raise
@@ -368,13 +403,74 @@ async def connect(
if classic: if classic:
if not address: if not address:
raise PrinterError("--address is required for Classic Bluetooth (no scanning)") raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
async with RFCOMMClient(address, channel) as client: # 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) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc 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: else:
addr = address or await find_printer() target = await resolve_ble_target(address)
async with BleakClient(addr) as client: 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
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try:
async with BleakClient(target) as client:
pc = PrinterClient(client) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc 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:
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.")

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "fichero-printer" name = "fichero-printer"
version = "0.1.1" version = "0.1.13"
description = "Fichero D11s thermal label printer - BLE CLI tool" description = "Fichero D11s thermal label printer - BLE CLI tool"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [

View File

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

View File

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

View File

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

View File

@@ -209,3 +209,105 @@
--bs-progress-bg: var(--surface-1); --bs-progress-bg: var(--surface-1);
--bs-progress-bar-bg: var(--fichero); --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);
}