release/0.1.20 #4

Merged
Paul merged 2 commits from release/0.1.20 into main 2026-03-16 09:21:35 +00:00
4 changed files with 453 additions and 294 deletions
Showing only changes of commit 8520a88197 - Show all commits

View File

@@ -4,6 +4,36 @@ All notable changes to this project are documented in this file.
The format is based on Keep a Changelog and this project uses Semantic Versioning. 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 ## [0.1.15] - 2026-03-07
### Fixed ### Fixed

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.13", 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

@@ -1,5 +1,21 @@
# 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 ## 0.1.15
- Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing. - Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing.