8 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
14 changed files with 1190 additions and 635 deletions

View File

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

View File

@@ -140,6 +140,18 @@ asyncio.run(main())
The package exports `PrinterClient`, `connect`, `PrinterError`, `PrinterNotFound`, `PrinterTimeout`, `PrinterNotReady`, and `PrinterStatus`.
## Troubleshooting
### Classic Bluetooth: [Errno 12] Out of memory
If you encounter `[Errno 12] Out of memory` failures on Classic Bluetooth connections, it typically implies a stale state in the BlueZ stack or the printer's radio. As of v0.1.17, the library automatically falls back to a BLE connection when this specific error occurs.
If you wish to resolve the underlying Classic Bluetooth issue, these steps can help:
- **Power cycle the printer**: This clears the printer's radio state and is often the only fix if the device is rejecting RFCOMM.
- **Verify Pairing**: Classic Bluetooth (RFCOMM) requires the device to be paired and trusted in the OS. You can use the "Pair Device" or "Unpair Device" buttons in the Home Assistant add-on's web UI, or run `bluetoothctl pair <MAC>` and `bluetoothctl trust <MAC>` (or `bluetoothctl remove <MAC>`) on the host. Pairing is not required for BLE.
- **Restart Bluetooth**: `systemctl restart bluetooth` on the host can clear stuck socket handles.
## TODO
- [ ] Emoji support in text labels. The default Pillow font has no emoji glyphs, so they render as squares. Needs two-pass rendering: split text into emoji/non-emoji segments, render emoji with Apple Color Emoji (macOS) or Noto Color Emoji (Linux) using `embedded_color=True`, then composite onto the label.

View File

@@ -5,6 +5,7 @@ Start with:
or:
python -m fichero.api
Endpoints:
GET /status Printer status
GET /info Printer info (model, firmware, battery, …)
@@ -17,8 +18,10 @@ from __future__ import annotations
import argparse
import asyncio
import io
import re
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
@@ -72,7 +75,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI(
title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.9",
version="0.1.20",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
@@ -94,301 +97,20 @@ def _address(address: str | None) -> str | None:
def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
return f"""<!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">
try:
template_path = Path(__file__).parent / "index.html"
template = template_path.read_text(encoding="utf-8")
except FileNotFoundError:
return "<h1>Error: index.html not found</h1>"
<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>"""
# Simple substitution for initial values
return (
template.replace("{default_address}", default_address)
.replace("{ble_selected}", " selected" if default_transport == "ble" else "")
.replace("{classic_selected}", " selected" if default_transport == "classic" else "")
.replace("{default_channel}", str(_DEFAULT_CHANNEL))
)
# ---------------------------------------------------------------------------
@@ -468,6 +190,90 @@ async def get_info(
return info
@app.post(
"/pair",
summary="Pair and trust a Bluetooth device",
status_code=200,
)
async def pair_device(
address: Annotated[str | None, Form(description="Device address (optional, overrides FICHERO_ADDR)")] = None,
):
"""
Attempt to pair and trust the device using `bluetoothctl`.
This is intended for setting up Classic Bluetooth connections.
"""
addr = _address(address)
if not addr:
raise HTTPException(status_code=422, detail="Address is required to pair.")
# Basic validation for MAC address format to mitigate injection risk.
if not re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", addr, re.IGNORECASE):
raise HTTPException(status_code=422, detail=f"Invalid address format: {addr}")
cmd = f'echo -e "pair {addr}\\ntrust {addr}\\nquit" | bluetoothctl'
try:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="`bluetoothctl` command not found. Is BlueZ installed and in PATH?")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="`bluetoothctl` command timed out after 15 seconds.")
output = stdout.decode(errors="ignore")
error = stderr.decode(errors="ignore")
if "Failed to pair" in output or "not available" in output.lower():
raise HTTPException(status_code=502, detail=f"Pairing failed. Output: {output}. Error: {error}")
return {"ok": True, "message": "Pair/trust command sent. Check output for details.", "output": output, "error": error}
@app.post(
"/unpair",
summary="Unpair a Bluetooth device",
status_code=200,
)
async def unpair_device(
address: Annotated[str | None, Form(description="Device address (optional, overrides FICHERO_ADDR)")] = None,
):
"""
Attempt to unpair the device using `bluetoothctl`.
"""
addr = _address(address)
if not addr:
raise HTTPException(status_code=422, detail="Address is required to unpair.")
# Basic validation for MAC address format to mitigate injection risk.
if not re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", addr, re.IGNORECASE):
raise HTTPException(status_code=422, detail=f"Invalid address format: {addr}")
cmd = f'echo -e "remove {addr}\\nquit" | bluetoothctl'
try:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="`bluetoothctl` command not found. Is BlueZ installed and in PATH?")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="`bluetoothctl` command timed out after 15 seconds.")
output = stdout.decode(errors="ignore")
error = stderr.decode(errors="ignore")
if "Failed to remove" in output or "not available" in output.lower():
raise HTTPException(status_code=502, detail=f"Unpairing failed. Output: {output}. Error: {error}")
return {"ok": True, "message": "Unpair command sent. Check output for details.", "output": output, "error": error}
@app.post(
"/print/text",
summary="Print a text label",

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 sys
import errno
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
@@ -98,7 +99,19 @@ async def resolve_ble_target(address: str | None = None):
"""
if address:
device = await BleakScanner.find_device_by_address(address, timeout=8.0)
return device or address
if device is not None:
return device
# Fallback to active scan/match before giving up; do not fall back to
# raw address because BlueZ may then attempt BR/EDR and fail with
# br-connection-not-supported.
devices = await BleakScanner.discover(timeout=8)
for d in devices:
if d.address and d.address.lower() == address.lower():
return d
raise PrinterNotFound(
f"BLE device {address} not found during scan. "
"Ensure printer is on, awake, and in range."
)
devices = await BleakScanner.discover(timeout=8)
for d in devices:
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
@@ -415,20 +428,47 @@ async def connect(
yield pc
return
except (PrinterError, PrinterTimeout) as exc:
# On Linux, a stale BlueZ device state can cause RFCOMM connect()
# to fail with [Errno 12] Out of memory. This is a known quirk.
# We treat this specific error as a signal to fall back to BLE.
if isinstance(exc.__cause__, OSError) and exc.__cause__.errno == errno.ENOMEM:
print(
"Classic Bluetooth connection failed with [Errno 12] Out of memory. "
"Falling back to BLE connection."
)
classic = False # Modify flag to trigger BLE path below
last_exc = exc
break
last_exc = exc
# If the 'classic' flag is still true, it means the loop completed without
# hitting the ENOMEM fallback case, so all classic attempts failed.
if classic:
if last_exc is not None:
raise PrinterError(
f"Classic Bluetooth connection failed for '{address}'. "
f"Tried channels: {channels}. Last error: {last_exc}"
) from last_exc
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
else:
# If classic=False initially, or if it was set to False for the ENOMEM fallback:
if not classic:
target = await resolve_ble_target(address)
def _is_retryable_ble_error(exc: Exception) -> bool:
msg = str(exc).lower()
return any(token in msg for token in ("timeout", "timed out", "br-connection-timeout"))
return any(
token in msg
for token in (
"timeout",
"timed out",
"br-connection-timeout",
"failed to discover services",
"device disconnected",
)
)
last_exc: Exception | None = None
forced_rescan_done = False
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try:
async with BleakClient(target) as client:
@@ -445,8 +485,15 @@ async def connect(
except BleakDBusError as exc:
msg = str(exc).lower()
if "br-connection-not-supported" in msg:
last_exc = exc
if not forced_rescan_done:
forced_rescan_done = True
target = await resolve_ble_target(None)
if attempt < BLE_CONNECT_RETRIES:
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
continue
raise PrinterError(
"BLE connection failed (br-connection-not-supported). "
"BLE connection failed (br-connection-not-supported) after LE rescan. "
"Try Classic Bluetooth with classic=true and channel=1."
) from exc
last_exc = exc

View File

@@ -1,5 +1,33 @@
# 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.

View File

@@ -1,5 +1,5 @@
name: "Fichero Printer"
version: "0.1.12"
version: "0.1.15"
slug: "fichero_printer"
description: "REST API for the Fichero D11s (AiYin) thermal label printer over Bluetooth"
url: "https://git.leuschner.dev/Tobias/Fichero"

View File

@@ -73,7 +73,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI(
title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.9",
version="0.1.13",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
@@ -96,139 +96,270 @@ def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
return f"""<!doctype html>
<html lang="en">
<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 {{
--bg: #f4efe6;
--panel: #fffaf2;
--line: #d8cdbd;
--ink: #2d241d;
--muted: #6c6258;
--accent: #b55e33;
--accent-2: #245b4b;
--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;
}}
* {{ box-sizing: border-box; }}
html {{ height: 100%; }}
body {{
margin: 0;
font-family: "Noto Sans", system-ui, sans-serif;
min-height: 100dvh;
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(circle at top left, #fff8ed 0, transparent 35%),
linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%);
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: 980px;
max-width: 1000px;
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 {{
padding: 24px 16px 60px;
display: grid;
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 {{
padding: 18px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06);
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;
margin: 10px 0 6px;
font-size: 0.92rem;
font-size: .8rem;
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%;
border-radius: 10px;
border: 1px solid var(--line);
padding: 10px 12px;
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;
}}
textarea {{ min-height: 110px; resize: vertical; }}
.row {{
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
input:focus, select:focus, textarea:focus {{
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(7,173,223,.2);
}}
.inline {{
display: flex;
gap: 10px;
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;
margin-top: 12px;
}}
.inline input[type="checkbox"] {{ width: auto; }}
button {{
cursor: pointer;
font-weight: 700;
color: #fff;
background: var(--accent);
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;
}}
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 {{
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;
margin: 0;
padding: 12px;
border-radius: 12px;
background: #241f1a;
color: #f7efe4;
min-height: 140px;
white-space: pre-wrap;
word-break: break-all;
}}
.actions {{
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
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;
}}
.actions button {{
width: auto;
min-width: 140px;
/* ── Footer ─────────────────────────────────────── */
footer {{
text-align: center;
padding: 16px;
font-size: .72rem;
color: var(--dim);
}}
@media (max-width: 640px) {{
.row {{ grid-template-columns: 1fr; }}
.actions button {{ width: 100%; }}
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>
<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">
<h2>Connection</h2>
<label for="address">Printer address</label>
<div style="display:flex;gap:8px;align-items:center">
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0" style="flex:1;width:auto">
<button type="button" id="scan-btn" class="alt" style="width:auto;white-space:nowrap" onclick="scanAddress()">&#128268; Scan</button>
<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="row">
<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</option>
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic BT</option>
</select>
</div>
<div>
@@ -237,28 +368,32 @@ def _ui_html() -> str:
</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 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">
<h2>Output</h2>
<pre id="output">Ready.</pre>
<p class="card-title">Response</p>
<pre id="output">Waiting for a command…</pre>
</div>
<!-- Print text card -->
<div class="card">
<h2>Print Text</h2>
<p class="card-title">Print text</p>
<label for="text">Text</label>
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
<div class="row">
<div class="two-col">
<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>
<option value="0">0 Light</option>
<option value="1">1 Medium</option>
<option value="2" selected>2 Dark</option>
</select>
</div>
<div>
@@ -266,9 +401,9 @@ def _ui_html() -> str:
<input id="text_copies" type="number" min="1" max="99" value="1">
</div>
</div>
<div class="row">
<div class="two-col">
<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">
</div>
<div>
@@ -276,28 +411,33 @@ def _ui_html() -> str:
<input id="text_label_length" type="number" min="5" max="500" value="30">
</div>
</div>
<label for="text_paper">Paper</label>
<label for="text_paper">Paper type</label>
<select id="text_paper">
<option value="gap" selected>gap</option>
<option value="black">black</option>
<option value="continuous">continuous</option>
<option value="gap" selected>Gap / label</option>
<option value="black">Black mark</option>
<option value="continuous">Continuous</option>
</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>
<!-- Print image card -->
<div class="card">
<h2>Print Image</h2>
<label for="image_file">Image file</label>
<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="row">
<div class="two-col">
<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>
<option value="0">0 Light</option>
<option value="1">1 Medium</option>
<option value="2" selected>2 Dark</option>
</select>
</div>
<div>
@@ -305,80 +445,112 @@ def _ui_html() -> str:
<input id="image_copies" type="number" min="1" max="99" value="1">
</div>
</div>
<div class="row">
<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 class="inline">
<input id="image_dither" type="checkbox" checked>
<label for="image_dither">Enable dithering</label>
</div>
</div>
<label for="image_paper">Paper</label>
<div>
<label for="image_paper">Paper type</label>
<select id="image_paper">
<option value="gap" selected>gap</option>
<option value="black">black</option>
<option value="continuous">continuous</option>
<option value="gap" selected>Gap / label</option>
<option value="black">Black mark</option>
<option value="continuous">Continuous</option>
</select>
<div class="actions">
<button type="button" onclick="printImage()">Print Image</button>
</div>
</div>
</section>
<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 params = new URLSearchParams();
if (address) params.set("address", address);
params.set("classic", String(classic));
params.set("channel", channel);
return params;
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) {{
const output = document.getElementById("output");
let data;
try {{
data = await response.json();
}} catch {{
data = {{ detail: await response.text() }};
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>';
}}
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 scanAddress() {{
const btn = document.getElementById("scan-btn");
const output = document.getElementById("output");
btn.disabled = true;
btn.textContent = "Scanning…";
output.textContent = "Scanning for printer (up to 8 s)…";
try {{
const response = await fetch("scan");
const data = await response.json();
if (response.ok && data.address) {{
document.getElementById("address").value = data.address;
output.textContent = JSON.stringify({{ status: response.status, ok: true, data }}, null, 2);
}} else {{
output.textContent = JSON.stringify({{ status: response.status, ok: false, data }}, null, 2);
}}
}} catch (e) {{
output.textContent = "Scan failed: " + e;
}} finally {{
btn.disabled = false;
btn.innerHTML = "&#128268; Scan";
}}
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);
@@ -390,18 +562,18 @@ def _ui_html() -> str:
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);
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 fileInput = document.getElementById("image_file");
if (!fileInput.files.length) {{
document.getElementById("output").textContent = "Select an image file first.";
return;
}}
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", fileInput.files[0]);
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);
@@ -410,8 +582,10 @@ def _ui_html() -> str:
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);
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>

View File

@@ -98,7 +98,19 @@ async def resolve_ble_target(address: str | None = None):
"""
if address:
device = await BleakScanner.find_device_by_address(address, timeout=8.0)
return device or address
if device is not None:
return device
# Fallback to active scan/match before giving up; do not fall back to
# raw address because BlueZ may then attempt BR/EDR and fail with
# br-connection-not-supported.
devices = await BleakScanner.discover(timeout=8)
for d in devices:
if d.address and d.address.lower() == address.lower():
return d
raise PrinterNotFound(
f"BLE device {address} not found during scan. "
"Ensure printer is on, awake, and in range."
)
devices = await BleakScanner.discover(timeout=8)
for d in devices:
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
@@ -426,9 +438,19 @@ async def connect(
target = await resolve_ble_target(address)
def _is_retryable_ble_error(exc: Exception) -> bool:
msg = str(exc).lower()
return any(token in msg for token in ("timeout", "timed out", "br-connection-timeout"))
return any(
token in msg
for token in (
"timeout",
"timed out",
"br-connection-timeout",
"failed to discover services",
"device disconnected",
)
)
last_exc: Exception | None = None
forced_rescan_done = False
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try:
async with BleakClient(target) as client:
@@ -445,8 +467,15 @@ async def connect(
except BleakDBusError as exc:
msg = str(exc).lower()
if "br-connection-not-supported" in msg:
last_exc = exc
if not forced_rescan_done:
forced_rescan_done = True
target = await resolve_ble_target(None)
if attempt < BLE_CONNECT_RETRIES:
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
continue
raise PrinterError(
"BLE connection failed (br-connection-not-supported). "
"BLE connection failed (br-connection-not-supported) after LE rescan. "
"Try Classic Bluetooth with classic=true and channel=1."
) from exc
last_exc = exc

View File

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

View File

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

View File

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

View File

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

View File

@@ -209,3 +209,105 @@
--bs-progress-bg: var(--surface-1);
--bs-progress-bar-bg: var(--fichero);
}
// ── Body background ────────────────────────────────────────────
body {
min-height: 100dvh;
background-image:
radial-gradient(ellipse at 15% 85%, rgba(var(--fichero-rgb), 0.06) 0%, transparent 55%),
radial-gradient(ellipse at 85% 8%, rgba(var(--fichero-rgb), 0.04) 0%, transparent 55%);
background-attachment: fixed;
}
// ── App header ─────────────────────────────────────────────────
.app-header {
position: sticky;
top: 0;
z-index: 1030;
padding: 7px 0;
background: rgba(22, 24, 25, 0.82);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border-standard);
box-shadow: 0 1px 0 0 var(--border-soft);
}
.app-brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
color: inherit;
&:hover {
color: inherit;
}
}
.app-brand-logo {
height: 1.75em;
border-radius: 5px;
box-shadow: 0 0 0 1px var(--border-soft);
}
.app-brand-name {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--ink-primary);
em {
color: var(--fichero);
font-style: normal;
}
}
// ── Toolbar bar ────────────────────────────────────────────────
.toolbar-bar {
background: var(--surface-1);
border: 1px solid var(--border-standard);
border-radius: var(--radius-lg);
padding: 7px 12px;
.btn-sm {
border-radius: var(--radius-sm) !important;
}
}
// ── Canvas panel ───────────────────────────────────────────────
.canvas-panel {
display: inline-flex;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow:
0 0 0 1px var(--border-standard),
0 16px 48px rgba(0, 0, 0, 0.40),
0 4px 12px rgba(0, 0, 0, 0.25);
}
// ── Scrollbar ─────────────────────────────────────────────────
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--surface-0); }
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 3px;
&:hover { background: var(--ink-muted); }
}
// ── Transition helpers ─────────────────────────────────────────
.btn { transition: background-color 0.15s, box-shadow 0.15s, border-color 0.15s; }
.btn-primary:not(:disabled):hover {
box-shadow: 0 0 0 3px rgba(var(--fichero-rgb), 0.25);
}
.btn-danger:not(:disabled):hover {
box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.25);
}