13 Commits

Author SHA1 Message Date
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
4dd04d1d34 Add printer scanning functionality and enhance UI for address input 2026-03-07 14:29:32 +01:00
14 changed files with 1275 additions and 613 deletions

View File

@@ -4,6 +4,61 @@ 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. The format is based on Keep a Changelog and this project uses Semantic Versioning.
## [0.1.20] - 2026-03-08
### Changed
- Refactored the embedded web UI in the API server to be loaded from a separate `index.html` file instead of a large inline string, improving maintainability.
## [0.1.19] - 2026-03-08
### Added
- Added `POST /unpair` endpoint and "Unpair Device" button in the web UI to remove a Bluetooth device from the host's paired devices.
## [0.1.18] - 2026-03-08
### Added
- Added `POST /pair` endpoint and "Pair Device" button in the web UI to easily pair/trust the printer via `bluetoothctl` for Classic Bluetooth connections.
## [0.1.17] - 2026-03-08
### Added
- Added automatic fallback to BLE connection if Classic Bluetooth (RFCOMM) fails with `[Errno 12] Out of memory`, a common issue on Linux with stale device states.
## [0.1.16] - 2026-03-08
### Fixed
- Corrected typos in the Code128B bit pattern table for characters '$' (ASCII 36) and ')' (ASCII 41), which caused incorrect barcodes to be generated.
## [0.1.15] - 2026-03-07
### Fixed
- 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 ## [0.1.10] - 2026-03-07
### Changed ### Changed

View File

@@ -140,6 +140,18 @@ asyncio.run(main())
The package exports `PrinterClient`, `connect`, `PrinterError`, `PrinterNotFound`, `PrinterTimeout`, `PrinterNotReady`, and `PrinterStatus`. 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 ## 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. - [ ] 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

@@ -5,6 +5,7 @@ Start with:
or: or:
python -m fichero.api python -m fichero.api
Endpoints: Endpoints:
GET /status Printer status GET /status Printer status
GET /info Printer info (model, firmware, battery, …) GET /info Printer info (model, firmware, battery, …)
@@ -17,8 +18,10 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import io import io
import re
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
@@ -72,7 +75,7 @@ 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.20",
lifespan=lifespan, lifespan=lifespan,
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
@@ -94,301 +97,20 @@ def _address(address: str | None) -> str | None:
def _ui_html() -> str: def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or "" default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble" 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"> try:
<div class="card"> template_path = Path(__file__).parent / "index.html"
<h2>Connection</h2> template = template_path.read_text(encoding="utf-8")
<label for="address">Printer address</label> except FileNotFoundError:
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0"> return "<h1>Error: index.html not found</h1>"
<div class="row"> # Simple substitution for initial values
<div> return (
<label for="transport">Transport</label> template.replace("{default_address}", default_address)
<select id="transport"> .replace("{ble_selected}", " selected" if default_transport == "ble" else "")
<option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option> .replace("{classic_selected}", " selected" if default_transport == "classic" else "")
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic</option> .replace("{default_channel}", str(_DEFAULT_CHANNEL))
</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>"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -468,6 +190,90 @@ async def get_info(
return 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( @app.post(
"/print/text", "/print/text",
summary="Print a text label", summary="Print a text label",

307
fichero/index.html Normal file
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,6 +8,7 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
import asyncio import asyncio
import sys import sys
import errno
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -90,6 +91,35 @@ 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)
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 --- # --- Status ---
@@ -398,32 +428,72 @@ async def connect(
yield pc yield pc
return return
except (PrinterError, PrinterTimeout) as exc: 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 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: if last_exc is not None:
raise PrinterError( raise PrinterError(
f"Classic Bluetooth connection failed for '{address}'. " f"Classic Bluetooth connection failed for '{address}'. "
f"Tried channels: {channels}. Last error: {last_exc}" f"Tried channels: {channels}. Last error: {last_exc}"
) from last_exc ) from last_exc
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
else:
addr = address or await find_printer() # 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: def _is_retryable_ble_error(exc: Exception) -> bool:
msg = str(exc).lower() msg = str(exc).lower()
return any(token in msg for token in ("timeout", "timed out", "br-connection-timeout")) return any(
token in msg
for token in (
"timeout",
"timed out",
"br-connection-timeout",
"failed to discover services",
"device disconnected",
)
)
last_exc: Exception | None = None last_exc: Exception | None = None
forced_rescan_done = False
for attempt in range(1, BLE_CONNECT_RETRIES + 1): for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try: try:
async with BleakClient(addr) as client: async with BleakClient(target) as client:
pc = PrinterClient(client) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc yield pc
return 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: except BleakDBusError as exc:
msg = str(exc).lower() msg = str(exc).lower()
if "br-connection-not-supported" in msg: 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( raise PrinterError(
"BLE connection failed (br-connection-not-supported). " "BLE connection failed (br-connection-not-supported) after LE rescan. "
"Try Classic Bluetooth with classic=true and channel=1." "Try Classic Bluetooth with classic=true and channel=1."
) from exc ) from exc
last_exc = exc last_exc = exc

View File

@@ -1,5 +1,41 @@
# Changelog # Changelog
## 0.1.20
- Refactored the embedded web UI to be loaded from an external `index.html` file.
## 0.1.19
- Added "Unpair Device" button to the web UI.
## 0.1.18
- Added "Pair Device" button to the web UI.
## 0.1.16
- Added automatic fallback to BLE if Classic Bluetooth fails with `[Errno 12] Out of memory`.
## 0.1.15
- Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing.
## 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 ## 0.1.10
- Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`). - Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`).

View File

@@ -1,5 +1,5 @@
name: "Fichero Printer" name: "Fichero Printer"
version: "0.1.10" version: "0.1.15"
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"

View File

@@ -35,6 +35,7 @@ from fichero.printer import (
PrinterNotFound, PrinterNotFound,
PrinterTimeout, PrinterTimeout,
connect, connect,
find_printer,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -72,7 +73,7 @@ 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, docs_url=None,
redoc_url=None, redoc_url=None,
@@ -95,136 +96,270 @@ def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or "" default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble" default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
return f"""<!doctype html> return f"""<!doctype html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fichero Printer</title> <title>Fichero Printer</title>
<style> <style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
:root {{ :root {{
--bg: #f4efe6; --s0: #161819;
--panel: #fffaf2; --s1: #1c1e20;
--line: #d8cdbd; --s2: #232628;
--ink: #2d241d; --s3: #2a2d30;
--muted: #6c6258; --ink: #e4e7eb;
--accent: #b55e33; --muted: #949ba4;
--accent-2: #245b4b; --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;
}} }}
* {{ box-sizing: border-box; }}
html {{ height: 100%; }}
body {{ body {{
margin: 0; min-height: 100dvh;
font-family: "Noto Sans", system-ui, sans-serif; font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--ink); color: var(--ink);
background: background-color: var(--s0);
radial-gradient(circle at top left, #fff8ed 0, transparent 35%), background-image:
linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%); 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 {{ main {{
max-width: 980px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px 40px; padding: 24px 16px 60px;
}}
.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; display: grid;
gap: 16px; gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}} }}
main > .full {{ grid-column: 1 / -1; }}
/* ── Cards ──────────────────────────────────────── */
.card {{ .card {{
padding: 18px; padding: 20px;
border: 1px solid var(--line); background: var(--s1);
border-radius: 16px; border: 1px solid var(--border);
background: var(--panel); border-radius: var(--r-lg);
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06); 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 {{ label {{
display: block; display: block;
margin: 10px 0 6px; font-size: .8rem;
font-size: 0.92rem;
font-weight: 600; font-weight: 600;
color: var(--muted);
margin: 12px 0 5px;
}} }}
input, select, textarea, button {{ label:first-child {{ margin-top: 0; }}
input, select, textarea {{
width: 100%; width: 100%;
border-radius: 10px; padding: 8px 11px;
border: 1px solid var(--line); border-radius: var(--r);
padding: 10px 12px; border: 1px solid var(--ctrl-border);
background: var(--ctrl-bg);
color: var(--ink);
font: inherit; font: inherit;
transition: border-color .15s, box-shadow .15s;
outline: none;
}} }}
textarea {{ min-height: 110px; resize: vertical; }} input:focus, select:focus, textarea:focus {{
.row {{ border-color: var(--brand);
display: grid; box-shadow: 0 0 0 3px rgba(7,173,223,.2);
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}} }}
.inline {{ textarea {{ min-height: 90px; resize: vertical; }}
display: flex; .two-col {{ display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }}
gap: 10px; .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; align-items: center;
margin-top: 12px; justify-content: center;
}} gap: 6px;
.inline input[type="checkbox"] {{ width: auto; }} padding: 8px 16px;
button {{ border-radius: var(--r);
cursor: pointer;
font-weight: 700;
color: #fff;
background: var(--accent);
border: none; border: none;
font: inherit;
font-size: .85rem;
font-weight: 600;
cursor: pointer;
transition: background .15s, box-shadow .15s, opacity .15s;
}} }}
button.alt {{ background: var(--accent-2); }} .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 {{ 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; overflow: auto;
margin: 0; white-space: pre-wrap;
padding: 12px; word-break: break-all;
border-radius: 12px;
background: #241f1a;
color: #f7efe4;
min-height: 140px;
}} }}
.actions {{ pre.ok {{ color: var(--ok); border-color: rgba(107,184,138,.2); }}
display: flex; pre.err {{ color: var(--err); border-color: rgba(212,84,84,.2); }}
gap: 10px;
flex-wrap: wrap; /* ── Spinner ────────────────────────────────────── */
margin-top: 12px; @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;
}} }}
.actions button {{
width: auto; /* ── Footer ─────────────────────────────────────── */
min-width: 140px; footer {{
text-align: center;
padding: 16px;
font-size: .72rem;
color: var(--dim);
}} }}
@media (max-width: 640px) {{ footer a {{ color: var(--dim); text-decoration: none; }}
.row {{ grid-template-columns: 1fr; }} footer a:hover {{ color: var(--brand); }}
.actions button {{ width: 100%; }}
@media (max-width: 520px) {{
.two-col {{ grid-template-columns: 1fr; }}
.btn-group .btn {{ min-width: 0; }}
}} }}
</style> </style>
</head> </head>
<body> <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"> <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"> <div class="card">
<h2>Connection</h2> <p class="card-title">Connection</p>
<label for="address">Printer address</label>
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0">
<div class="row"> <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> <div>
<label for="transport">Transport</label> <label for="transport">Transport</label>
<select id="transport"> <select id="transport">
<option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option> <option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option>
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic</option> <option value="classic"{" selected" if default_transport == "classic" else ""}>Classic BT</option>
</select> </select>
</div> </div>
<div> <div>
@@ -233,28 +368,32 @@ def _ui_html() -> str:
</div> </div>
</div> </div>
<div class="actions"> <div class="btn-group">
<button type="button" class="alt" onclick="runGet('status')">Get Status</button> <button class="btn btn-secondary" onclick="runGet('status')">Status</button>
<button type="button" class="alt" onclick="runGet('info')">Get Info</button> <button class="btn btn-secondary" onclick="runGet('info')">Device info</button>
</div> </div>
</div> </div>
<!-- Output card -->
<div class="card"> <div class="card">
<h2>Output</h2> <p class="card-title">Response</p>
<pre id="output">Ready.</pre> <pre id="output">Waiting for a command…</pre>
</div> </div>
<!-- Print text card -->
<div class="card"> <div class="card">
<h2>Print Text</h2> <p class="card-title">Print text</p>
<label for="text">Text</label> <label for="text">Text</label>
<textarea id="text" placeholder="Hello from Home Assistant"></textarea> <textarea id="text" placeholder="Hello from Home Assistant"></textarea>
<div class="row">
<div class="two-col">
<div> <div>
<label for="text_density">Density</label> <label for="text_density">Density</label>
<select id="text_density"> <select id="text_density">
<option value="0">0</option> <option value="0">0 Light</option>
<option value="1">1</option> <option value="1">1 Medium</option>
<option value="2" selected>2</option> <option value="2" selected>2 Dark</option>
</select> </select>
</div> </div>
<div> <div>
@@ -262,9 +401,9 @@ def _ui_html() -> str:
<input id="text_copies" type="number" min="1" max="99" value="1"> <input id="text_copies" type="number" min="1" max="99" value="1">
</div> </div>
</div> </div>
<div class="row"> <div class="two-col">
<div> <div>
<label for="text_font_size">Font size</label> <label for="text_font_size">Font size (pt)</label>
<input id="text_font_size" type="number" min="6" max="200" value="30"> <input id="text_font_size" type="number" min="6" max="200" value="30">
</div> </div>
<div> <div>
@@ -272,28 +411,33 @@ def _ui_html() -> str:
<input id="text_label_length" type="number" min="5" max="500" value="30"> <input id="text_label_length" type="number" min="5" max="500" value="30">
</div> </div>
</div> </div>
<label for="text_paper">Paper</label>
<label for="text_paper">Paper type</label>
<select id="text_paper"> <select id="text_paper">
<option value="gap" selected>gap</option> <option value="gap" selected>Gap / label</option>
<option value="black">black</option> <option value="black">Black mark</option>
<option value="continuous">continuous</option> <option value="continuous">Continuous</option>
</select> </select>
<div class="actions">
<button type="button" onclick="printText()">Print Text</button> <div class="btn-group">
<button class="btn btn-primary" onclick="printText()">🖨 Print text</button>
</div> </div>
</div> </div>
<!-- Print image card -->
<div class="card"> <div class="card">
<h2>Print Image</h2> <p class="card-title">Print image</p>
<label for="image_file">Image file</label>
<label for="image_file">Image file (PNG / JPEG / BMP / WEBP)</label>
<input id="image_file" type="file" accept="image/*"> <input id="image_file" type="file" accept="image/*">
<div class="row">
<div class="two-col">
<div> <div>
<label for="image_density">Density</label> <label for="image_density">Density</label>
<select id="image_density"> <select id="image_density">
<option value="0">0</option> <option value="0">0 Light</option>
<option value="1">1</option> <option value="1">1 Medium</option>
<option value="2" selected>2</option> <option value="2" selected>2 Dark</option>
</select> </select>
</div> </div>
<div> <div>
@@ -301,57 +445,112 @@ def _ui_html() -> str:
<input id="image_copies" type="number" min="1" max="99" value="1"> <input id="image_copies" type="number" min="1" max="99" value="1">
</div> </div>
</div> </div>
<div class="row"> <div class="two-col">
<div> <div>
<label for="image_label_length">Label length (mm)</label> <label for="image_label_length">Label length (mm)</label>
<input id="image_label_length" type="number" min="5" max="500" value="30"> <input id="image_label_length" type="number" min="5" max="500" value="30">
</div> </div>
<div class="inline"> <div>
<input id="image_dither" type="checkbox" checked> <label for="image_paper">Paper type</label>
<label for="image_dither">Enable dithering</label>
</div>
</div>
<label for="image_paper">Paper</label>
<select id="image_paper"> <select id="image_paper">
<option value="gap" selected>gap</option> <option value="gap" selected>Gap / label</option>
<option value="black">black</option> <option value="black">Black mark</option>
<option value="continuous">continuous</option> <option value="continuous">Continuous</option>
</select> </select>
<div class="actions">
<button type="button" onclick="printImage()">Print Image</button>
</div> </div>
</div> </div>
</section>
</main>
<script> <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() {{ function commonParams() {{
const address = document.getElementById("address").value.trim(); const address = document.getElementById("address").value.trim();
const classic = document.getElementById("transport").value === "classic"; const classic = document.getElementById("transport").value === "classic";
const channel = document.getElementById("channel").value; const channel = document.getElementById("channel").value;
const params = new URLSearchParams(); const p = new URLSearchParams();
if (address) params.set("address", address); if (address) p.set("address", address);
params.set("classic", String(classic)); p.set("classic", String(classic));
params.set("channel", channel); p.set("channel", channel);
return params; 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) {{ async function showResponse(response) {{
const output = document.getElementById("output");
let data; let data;
try {{ try {{ data = await response.json(); }} catch {{ data = {{ detail: await response.text() }}; }}
data = await response.json(); setOutput({{ status: response.status, ok: response.ok, data }}, response.ok);
}} catch {{ }}
data = {{ detail: await response.text() }};
// ── 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>';
}} }}
output.textContent = JSON.stringify({{ status: response.status, ok: response.ok, data }}, null, 2);
}} }}
async function runGet(path) {{ async function runGet(path) {{
const response = await fetch(`${{path}}?${{commonParams().toString()}}`); setOutput({{ message: "Loading…" }}, true);
await showResponse(response); const r = await fetch(path + "?" + commonParams().toString());
await showResponse(r);
}} }}
// ── Print text ───────────────────────────────────────
async function printText() {{ async function printText() {{
const form = new FormData(); const form = new FormData();
form.set("text", document.getElementById("text").value); form.set("text", document.getElementById("text").value);
@@ -363,18 +562,18 @@ def _ui_html() -> str:
form.set("address", document.getElementById("address").value.trim()); form.set("address", document.getElementById("address").value.trim());
form.set("classic", String(document.getElementById("transport").value === "classic")); form.set("classic", String(document.getElementById("transport").value === "classic"));
form.set("channel", document.getElementById("channel").value); form.set("channel", document.getElementById("channel").value);
const response = await fetch("print/text", {{ method: "POST", body: form }}); setOutput({{ message: "Sending to printer…" }}, true);
await showResponse(response); const r = await fetch("print/text", {{ method: "POST", body: form }});
await showResponse(r);
if (r.ok) refreshStatus();
}} }}
// ── Print image ──────────────────────────────────────
async function printImage() {{ async function printImage() {{
const fileInput = document.getElementById("image_file"); const fi = document.getElementById("image_file");
if (!fileInput.files.length) {{ if (!fi.files.length) {{ setOutput({{ error: "Select an image file first." }}, false); return; }}
document.getElementById("output").textContent = "Select an image file first.";
return;
}}
const form = new FormData(); const form = new FormData();
form.set("file", fileInput.files[0]); form.set("file", fi.files[0]);
form.set("density", document.getElementById("image_density").value); form.set("density", document.getElementById("image_density").value);
form.set("copies", document.getElementById("image_copies").value); form.set("copies", document.getElementById("image_copies").value);
form.set("label_length", document.getElementById("image_label_length").value); form.set("label_length", document.getElementById("image_label_length").value);
@@ -383,10 +582,12 @@ def _ui_html() -> str:
form.set("address", document.getElementById("address").value.trim()); form.set("address", document.getElementById("address").value.trim());
form.set("classic", String(document.getElementById("transport").value === "classic")); form.set("classic", String(document.getElementById("transport").value === "classic"));
form.set("channel", document.getElementById("channel").value); form.set("channel", document.getElementById("channel").value);
const response = await fetch("print/image", {{ method: "POST", body: form }}); setOutput({{ message: "Sending to printer…" }}, true);
await showResponse(response); const r = await fetch("print/image", {{ method: "POST", body: form }});
await showResponse(r);
if (r.ok) refreshStatus();
}} }}
</script> </script>
</body> </body>
</html>""" </html>"""
@@ -410,6 +611,22 @@ async def docs():
) )
@app.get(
"/scan",
summary="Scan for printer",
response_description="BLE address of the discovered printer",
)
async def scan_printer():
"""Scan for a Fichero/D11s printer via BLE and return its address."""
try:
address = await find_printer()
except PrinterNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"address": address}
@app.get( @app.get(
"/status", "/status",
summary="Get printer status", summary="Get printer status",

View File

@@ -90,6 +90,35 @@ 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)
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 --- # --- Status ---
@@ -406,24 +435,47 @@ async def connect(
) from last_exc ) from last_exc
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
else: else:
addr = address or await find_printer() target = await resolve_ble_target(address)
def _is_retryable_ble_error(exc: Exception) -> bool: def _is_retryable_ble_error(exc: Exception) -> bool:
msg = str(exc).lower() msg = str(exc).lower()
return any(token in msg for token in ("timeout", "timed out", "br-connection-timeout")) return any(
token in msg
for token in (
"timeout",
"timed out",
"br-connection-timeout",
"failed to discover services",
"device disconnected",
)
)
last_exc: Exception | None = None last_exc: Exception | None = None
forced_rescan_done = False
for attempt in range(1, BLE_CONNECT_RETRIES + 1): for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try: try:
async with BleakClient(addr) as client: async with BleakClient(target) as client:
pc = PrinterClient(client) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc yield pc
return 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: except BleakDBusError as exc:
msg = str(exc).lower() msg = str(exc).lower()
if "br-connection-not-supported" in msg: 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( raise PrinterError(
"BLE connection failed (br-connection-not-supported). " "BLE connection failed (br-connection-not-supported) after LE rescan. "
"Try Classic Bluetooth with classic=true and channel=1." "Try Classic Bluetooth with classic=true and channel=1."
) from exc ) from exc
last_exc = exc last_exc = exc

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "fichero-printer" name = "fichero-printer"
version = "0.1.10" version = "0.1.15"
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);
}