@@ -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: 98 0px;
max-width: 100 0px;
margin: 0 auto;
padding: 24px 16px 4 0px;
}}
.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 6 0px;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(28 0px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(29 0px, 1fr));
}}
main > .full {{ grid-column: 1 / -1; }}
/* ── Cards ──────────────────────────────────────── */
.card {{
padding: 18 px;
border: 1px solid var(--line );
border-radius : 16 px;
background : var(--panel );
box-shadow: 0 8px 24 px rgba(45, 36, 29, 0.06 );
padding: 20 px;
background: var(--s1 );
border: 1px solid var(--border) ;
border-radius : var(--r-lg );
box-shadow: 0 8px 3 2px 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: 10 px;
border: 1px solid var(--line );
padding : 10 px 12px ;
padding: 8px 11 px;
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= " addre ss " value= " { default_address } " placeholder= " C9:48:8A:69:D5:C0 " style= " flex:1;width:auto " >
<button type= " button " id= " scan-btn " cla ss= " alt " style= " width:auto;white-space:nowrap " onclick= " scanAddress() " >🔌 Scan</button >
<p class= " card-title " >Connection</p >
<label for= " address " >Bluetooth address</label >
<div cla ss= " addr-row " >
<input id= " addre ss " 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 I nfo</button>
<div class= " btn-group " >
<button class= " btn btn-secondary " onclick= " runGet( ' status ' ) " >Status</button>
<button class = " btn btn-secondary " onclick= " runGet( ' info ' ) " >Device i nfo</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 T ext</h2 >
<p class= " card-title " >Print t ext</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>g ap</option>
<option value= " black " >b lack</option>
<option value= " continuous " >c ontinuous</option>
<option value= " gap " selected>G ap / label </option>
<option value= " black " >B lack mark </option>
<option value= " continuous " >C ontinuous</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 I mage</h2 >
<label for= " image_file " >Image file</label>
<p class= " card-title " >Print i mage</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_dith er " type= " checkbox " checked >
<label for= " image_dither " >Enable dithering</label>
</div>
</div>
<label for= " image_paper " >Paper</label>
<div >
<label for = " image_pap er " >Paper type</label >
<select id= " image_paper " >
<option value= " gap " selected>g ap</option>
<option value= " black " >b lack</option>
<option value= " continuous " >c ontinuous</option>
<option value= " gap " selected>G ap / label </option>
<option value= " black " >B lack mark </option>
<option value= " continuous " >C ontinuous</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 ·
<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 = " 🔌 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>