9 Commits

Author SHA1 Message Date
9f191b564a Bump version to 0.1.13 in API and package.json
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:19:03 +01:00
paul2212
42e56e1b9f Retry BLE service-discovery disconnect errors and bump to 0.1.13
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:12:56 +01:00
7778a6f614 Merge branch 'main' of https://git.leuschner.dev/Tobias/Fichero
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:11:06 +01:00
9d77fbe366 Enhance UI layout and styles for LabelDesigner and MainPage components 2026-03-07 15:11:03 +01:00
paul2212
6b6d57bd77 Prefer BLE device object resolution and bump to 0.1.12
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 15:07:53 +01:00
paul2212
822dbd35b2 Handle BLE connect TimeoutError and bump to 0.1.11
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:40:12 +01:00
45d945a9d4 Merge branch 'main' of https://git.leuschner.dev/Tobias/Fichero
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
2026-03-07 14:30:06 +01:00
7317a60818 Bump version to 0.1.9 in API and package.json 2026-03-07 14:30:03 +01:00
4dd04d1d34 Add printer scanning functionality and enhance UI for address input 2026-03-07 14:29:32 +01:00
12 changed files with 726 additions and 311 deletions

View File

@@ -4,6 +4,21 @@ 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.13] - 2026-03-07
### Fixed
- Treated BLE service-discovery disconnects (`failed to discover services, device disconnected`) as retryable transient errors in the BLE connect loop.
## [0.1.12] - 2026-03-07
### Fixed
- BLE target resolution now prefers discovered Bleak device objects (instead of raw address strings), improving BlueZ LE connection handling on hosts that previously returned `br-connection-not-supported`.
## [0.1.11] - 2026-03-07
### Fixed
- Handled `asyncio.TimeoutError` from BLE connect path so connection timeouts now return mapped API errors (502) instead of unhandled 500 exceptions.
## [0.1.10] - 2026-03-07 ## [0.1.10] - 2026-03-07
### Changed ### Changed

View File

@@ -72,7 +72,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI( app = FastAPI(
title="Fichero Printer API", title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.", description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.0", version="0.1.13",
lifespan=lifespan, lifespan=lifespan,
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,

View File

@@ -90,6 +90,23 @@ async def find_printer() -> str:
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
async def resolve_ble_target(address: str | None = None):
"""Resolve a BLE target as Bleak device object when possible.
Passing a discovered device object to BleakClient helps BlueZ keep the
correct LE context for dual-mode environments.
"""
if address:
device = await BleakScanner.find_device_by_address(address, timeout=8.0)
return device or address
devices = await BleakScanner.discover(timeout=8)
for d in devices:
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
print(f" Found {d.name} at {d.address}")
return d
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
# --- Status --- # --- Status ---
@@ -406,19 +423,34 @@ async def connect(
) from last_exc ) from last_exc
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
else: else:
addr = address or await find_printer() target = await resolve_ble_target(address)
def _is_retryable_ble_error(exc: Exception) -> bool: def _is_retryable_ble_error(exc: Exception) -> bool:
msg = str(exc).lower() msg = str(exc).lower()
return any(token in msg for token in ("timeout", "timed out", "br-connection-timeout")) return any(
token in msg
for token in (
"timeout",
"timed out",
"br-connection-timeout",
"failed to discover services",
"device disconnected",
)
)
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(1, BLE_CONNECT_RETRIES + 1): for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try: try:
async with BleakClient(addr) as client: async with BleakClient(target) as client:
pc = PrinterClient(client) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc yield pc
return return
except asyncio.TimeoutError as exc:
last_exc = exc
if attempt < BLE_CONNECT_RETRIES:
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
continue
raise PrinterError(f"BLE connection timed out: {exc}") from exc
except BleakDBusError as exc: except BleakDBusError as exc:
msg = str(exc).lower() msg = str(exc).lower()
if "br-connection-not-supported" in msg: if "br-connection-not-supported" in msg:

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 0.1.13
- Marked BLE service-discovery disconnect errors as retryable (`failed to discover services, device disconnected`), so the add-on retries automatically.
## 0.1.12
- Improved BLE connection target resolution by preferring discovered BLE device objects over raw MAC strings to avoid BlueZ `br-connection-not-supported` on some hosts.
## 0.1.11
- Fixed unhandled BLE connect timeout (`asyncio.TimeoutError`) that previously caused HTTP 500 responses.
## 0.1.10 ## 0.1.10
- Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`). - Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`).

View File

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

View File

@@ -35,6 +35,7 @@ from fichero.printer import (
PrinterNotFound, PrinterNotFound,
PrinterTimeout, PrinterTimeout,
connect, connect,
find_printer,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -72,7 +73,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI( app = FastAPI(
title="Fichero Printer API", title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.", description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.0", version="0.1.13",
lifespan=lifespan, lifespan=lifespan,
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
@@ -95,136 +96,270 @@ def _ui_html() -> str:
default_address = _DEFAULT_ADDRESS or "" default_address = _DEFAULT_ADDRESS or ""
default_transport = "classic" if _DEFAULT_CLASSIC else "ble" default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
return f"""<!doctype html> return f"""<!doctype html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fichero Printer</title> <title>Fichero Printer</title>
<style> <style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
:root {{ :root {{
--bg: #f4efe6; --s0: #161819;
--panel: #fffaf2; --s1: #1c1e20;
--line: #d8cdbd; --s2: #232628;
--ink: #2d241d; --s3: #2a2d30;
--muted: #6c6258; --ink: #e4e7eb;
--accent: #b55e33; --muted: #949ba4;
--accent-2: #245b4b; --dim: #5d6670;
--brand: #07addf;
--brand-h: #0699c7;
--brand-glow: rgba(7,173,223,.18);
--ok: #6bb88a;
--warn: #d4a24c;
--err: #d45454;
--border: rgba(228,231,235,.08);
--border-e: rgba(228,231,235,.14);
--ctrl-bg: #141617;
--ctrl-border: rgba(228,231,235,.10);
--r: 10px;
--r-lg: 14px;
}} }}
* {{ box-sizing: border-box; }}
html {{ height: 100%; }}
body {{ body {{
margin: 0; min-height: 100dvh;
font-family: "Noto Sans", system-ui, sans-serif; font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--ink); color: var(--ink);
background: background-color: var(--s0);
radial-gradient(circle at top left, #fff8ed 0, transparent 35%), background-image:
linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%); radial-gradient(ellipse at 10% 90%, rgba(7,173,223,.07) 0%, transparent 55%),
radial-gradient(ellipse at 90% 5%, rgba(7,173,223,.04) 0%, transparent 55%);
background-attachment: fixed;
}} }}
/* ── Header ─────────────────────────────────────── */
header {{
position: sticky;
top: 0;
z-index: 100;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 12px;
background: rgba(22,24,25,.82);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border);
}}
.brand {{ font-size: 1.05rem; font-weight: 700; letter-spacing: -.025em; }}
.brand em {{ color: var(--brand); font-style: normal; }}
.header-right {{ margin-left: auto; display: flex; align-items: center; gap: 8px; }}
.status-pill {{
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
font-size: .78rem;
font-weight: 600;
background: var(--s2);
border: 1px solid var(--border-e);
color: var(--muted);
transition: .25s;
cursor: default;
white-space: nowrap;
}}
.status-pill .dot {{
width: 7px; height: 7px;
border-radius: 50%;
background: var(--dim);
transition: .25s;
}}
.status-pill.ok {{ color: var(--ok); border-color: rgba(107,184,138,.3); }}
.status-pill.ok .dot {{ background: var(--ok); box-shadow: 0 0 6px var(--ok); }}
.status-pill.err {{ color: var(--err); border-color: rgba(212,84,84,.3); }}
.status-pill.err .dot {{ background: var(--err); box-shadow: 0 0 6px var(--err); }}
/* ── Layout ─────────────────────────────────────── */
main {{ main {{
max-width: 980px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px 40px; padding: 24px 16px 60px;
}}
.hero {{
margin-bottom: 20px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 250, 242, 0.92);
backdrop-filter: blur(4px);
}}
h1, h2 {{ margin: 0 0 12px; }}
.muted {{ color: var(--muted); }}
.grid {{
display: grid; display: grid;
gap: 16px; gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}} }}
main > .full {{ grid-column: 1 / -1; }}
/* ── Cards ──────────────────────────────────────── */
.card {{ .card {{
padding: 18px; padding: 20px;
border: 1px solid var(--line); background: var(--s1);
border-radius: 16px; border: 1px solid var(--border);
background: var(--panel); border-radius: var(--r-lg);
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06); box-shadow: 0 8px 32px rgba(0,0,0,.3);
}} }}
.card-title {{
font-size: .7rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 14px;
}}
/* ── Form ───────────────────────────────────────── */
label {{ label {{
display: block; display: block;
margin: 10px 0 6px; font-size: .8rem;
font-size: 0.92rem;
font-weight: 600; font-weight: 600;
color: var(--muted);
margin: 12px 0 5px;
}} }}
input, select, textarea, button {{ label:first-child {{ margin-top: 0; }}
input, select, textarea {{
width: 100%; width: 100%;
border-radius: 10px; padding: 8px 11px;
border: 1px solid var(--line); border-radius: var(--r);
padding: 10px 12px; border: 1px solid var(--ctrl-border);
background: var(--ctrl-bg);
color: var(--ink);
font: inherit; font: inherit;
transition: border-color .15s, box-shadow .15s;
outline: none;
}} }}
textarea {{ min-height: 110px; resize: vertical; }} input:focus, select:focus, textarea:focus {{
.row {{ border-color: var(--brand);
display: grid; box-shadow: 0 0 0 3px rgba(7,173,223,.2);
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}} }}
.inline {{ textarea {{ min-height: 90px; resize: vertical; }}
display: flex; .two-col {{ display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }}
gap: 10px; .check-row {{ display: flex; align-items: center; gap: 8px; margin-top: 14px; }}
.check-row input[type=checkbox] {{ width: 15px; height: 15px; accent-color: var(--brand); }}
.check-row label {{ margin: 0; font-size: .85rem; color: var(--ink); }}
/* ── Buttons ────────────────────────────────────── */
.btn {{
display: inline-flex;
align-items: center; align-items: center;
margin-top: 12px; justify-content: center;
}} gap: 6px;
.inline input[type="checkbox"] {{ width: auto; }} padding: 8px 16px;
button {{ border-radius: var(--r);
cursor: pointer;
font-weight: 700;
color: #fff;
background: var(--accent);
border: none; border: none;
font: inherit;
font-size: .85rem;
font-weight: 600;
cursor: pointer;
transition: background .15s, box-shadow .15s, opacity .15s;
}} }}
button.alt {{ background: var(--accent-2); }} .btn:disabled {{ opacity: .5; cursor: not-allowed; }}
.btn-primary {{
background: var(--brand);
color: #fff;
}}
.btn-primary:not(:disabled):hover {{
background: var(--brand-h);
box-shadow: 0 0 0 3px var(--brand-glow);
}}
.btn-secondary {{
background: var(--s3);
color: var(--ink);
border: 1px solid var(--border-e);
}}
.btn-secondary:not(:disabled):hover {{
background: var(--s3);
border-color: var(--brand);
}}
.btn-group {{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }}
.btn-group .btn {{ flex: 1; min-width: 110px; }}
/* ── Address row ────────────────────────────────── */
.addr-row {{ display: flex; gap: 8px; }}
.addr-row input {{ flex: 1; min-width: 0; }}
.addr-row .btn {{ padding: 8px 12px; white-space: nowrap; flex-shrink: 0; }}
/* ── Output / pre ───────────────────────────────── */
pre {{ pre {{
background: var(--s0);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--muted);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
font-size: .78rem;
line-height: 1.6;
padding: 14px;
min-height: 130px;
overflow: auto; overflow: auto;
margin: 0; white-space: pre-wrap;
padding: 12px; word-break: break-all;
border-radius: 12px;
background: #241f1a;
color: #f7efe4;
min-height: 140px;
}} }}
.actions {{ pre.ok {{ color: var(--ok); border-color: rgba(107,184,138,.2); }}
display: flex; pre.err {{ color: var(--err); border-color: rgba(212,84,84,.2); }}
gap: 10px;
flex-wrap: wrap; /* ── Spinner ────────────────────────────────────── */
margin-top: 12px; @keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.spinner {{
display: inline-block;
width: 13px; height: 13px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .65s linear infinite;
}} }}
.actions button {{
width: auto; /* ── Footer ─────────────────────────────────────── */
min-width: 140px; footer {{
text-align: center;
padding: 16px;
font-size: .72rem;
color: var(--dim);
}} }}
@media (max-width: 640px) {{ footer a {{ color: var(--dim); text-decoration: none; }}
.row {{ grid-template-columns: 1fr; }} footer a:hover {{ color: var(--brand); }}
.actions button {{ width: 100%; }}
@media (max-width: 520px) {{
.two-col {{ grid-template-columns: 1fr; }}
.btn-group .btn {{ min-width: 0; }}
}} }}
</style> </style>
</head> </head>
<body> <body>
<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> <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"> <!-- Connection card -->
<div class="card"> <div class="card">
<h2>Connection</h2> <p class="card-title">Connection</p>
<label for="address">Printer address</label>
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0">
<div class="row"> <label for="address">Bluetooth address</label>
<div class="addr-row">
<input id="address" value="{default_address}" placeholder="AA:BB:CC:DD:EE:FF">
<button class="btn btn-secondary" id="scan-btn" onclick="scanAddress()">
<span id="scan-icon">⟳</span> Scan
</button>
</div>
<div class="two-col">
<div> <div>
<label for="transport">Transport</label> <label for="transport">Transport</label>
<select id="transport"> <select id="transport">
<option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option> <option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option>
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic</option> <option value="classic"{" selected" if default_transport == "classic" else ""}>Classic BT</option>
</select> </select>
</div> </div>
<div> <div>
@@ -233,28 +368,32 @@ def _ui_html() -> str:
</div> </div>
</div> </div>
<div class="actions"> <div class="btn-group">
<button type="button" class="alt" onclick="runGet('status')">Get Status</button> <button class="btn btn-secondary" onclick="runGet('status')">Status</button>
<button type="button" class="alt" onclick="runGet('info')">Get Info</button> <button class="btn btn-secondary" onclick="runGet('info')">Device info</button>
</div> </div>
</div> </div>
<!-- Output card -->
<div class="card"> <div class="card">
<h2>Output</h2> <p class="card-title">Response</p>
<pre id="output">Ready.</pre> <pre id="output">Waiting for a command…</pre>
</div> </div>
<!-- Print text card -->
<div class="card"> <div class="card">
<h2>Print Text</h2> <p class="card-title">Print text</p>
<label for="text">Text</label> <label for="text">Text</label>
<textarea id="text" placeholder="Hello from Home Assistant"></textarea> <textarea id="text" placeholder="Hello from Home Assistant"></textarea>
<div class="row">
<div class="two-col">
<div> <div>
<label for="text_density">Density</label> <label for="text_density">Density</label>
<select id="text_density"> <select id="text_density">
<option value="0">0</option> <option value="0">0 Light</option>
<option value="1">1</option> <option value="1">1 Medium</option>
<option value="2" selected>2</option> <option value="2" selected>2 Dark</option>
</select> </select>
</div> </div>
<div> <div>
@@ -262,9 +401,9 @@ def _ui_html() -> str:
<input id="text_copies" type="number" min="1" max="99" value="1"> <input id="text_copies" type="number" min="1" max="99" value="1">
</div> </div>
</div> </div>
<div class="row"> <div class="two-col">
<div> <div>
<label for="text_font_size">Font size</label> <label for="text_font_size">Font size (pt)</label>
<input id="text_font_size" type="number" min="6" max="200" value="30"> <input id="text_font_size" type="number" min="6" max="200" value="30">
</div> </div>
<div> <div>
@@ -272,28 +411,33 @@ def _ui_html() -> str:
<input id="text_label_length" type="number" min="5" max="500" value="30"> <input id="text_label_length" type="number" min="5" max="500" value="30">
</div> </div>
</div> </div>
<label for="text_paper">Paper</label>
<label for="text_paper">Paper type</label>
<select id="text_paper"> <select id="text_paper">
<option value="gap" selected>gap</option> <option value="gap" selected>Gap / label</option>
<option value="black">black</option> <option value="black">Black mark</option>
<option value="continuous">continuous</option> <option value="continuous">Continuous</option>
</select> </select>
<div class="actions">
<button type="button" onclick="printText()">Print Text</button> <div class="btn-group">
<button class="btn btn-primary" onclick="printText()">🖨 Print text</button>
</div> </div>
</div> </div>
<!-- Print image card -->
<div class="card"> <div class="card">
<h2>Print Image</h2> <p class="card-title">Print image</p>
<label for="image_file">Image file</label>
<label for="image_file">Image file (PNG / JPEG / BMP / WEBP)</label>
<input id="image_file" type="file" accept="image/*"> <input id="image_file" type="file" accept="image/*">
<div class="row">
<div class="two-col">
<div> <div>
<label for="image_density">Density</label> <label for="image_density">Density</label>
<select id="image_density"> <select id="image_density">
<option value="0">0</option> <option value="0">0 Light</option>
<option value="1">1</option> <option value="1">1 Medium</option>
<option value="2" selected>2</option> <option value="2" selected>2 Dark</option>
</select> </select>
</div> </div>
<div> <div>
@@ -301,57 +445,112 @@ def _ui_html() -> str:
<input id="image_copies" type="number" min="1" max="99" value="1"> <input id="image_copies" type="number" min="1" max="99" value="1">
</div> </div>
</div> </div>
<div class="row"> <div class="two-col">
<div> <div>
<label for="image_label_length">Label length (mm)</label> <label for="image_label_length">Label length (mm)</label>
<input id="image_label_length" type="number" min="5" max="500" value="30"> <input id="image_label_length" type="number" min="5" max="500" value="30">
</div> </div>
<div class="inline"> <div>
<input id="image_dither" type="checkbox" checked> <label for="image_paper">Paper type</label>
<label for="image_dither">Enable dithering</label>
</div>
</div>
<label for="image_paper">Paper</label>
<select id="image_paper"> <select id="image_paper">
<option value="gap" selected>gap</option> <option value="gap" selected>Gap / label</option>
<option value="black">black</option> <option value="black">Black mark</option>
<option value="continuous">continuous</option> <option value="continuous">Continuous</option>
</select> </select>
<div class="actions">
<button type="button" onclick="printImage()">Print Image</button>
</div> </div>
</div> </div>
</section>
<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> </main>
<footer>
Fichero Printer REST API &nbsp;·&nbsp;
<a href="docs">Swagger UI</a>
</footer>
<script> <script>
// ── Helpers ──────────────────────────────────────────
function commonParams() {{ function commonParams() {{
const address = document.getElementById("address").value.trim(); const address = document.getElementById("address").value.trim();
const classic = document.getElementById("transport").value === "classic"; const classic = document.getElementById("transport").value === "classic";
const channel = document.getElementById("channel").value; const channel = document.getElementById("channel").value;
const params = new URLSearchParams(); const p = new URLSearchParams();
if (address) params.set("address", address); if (address) p.set("address", address);
params.set("classic", String(classic)); p.set("classic", String(classic));
params.set("channel", channel); p.set("channel", channel);
return params; return p;
}}
function setOutput(obj, isOk) {{
const pre = document.getElementById("output");
pre.textContent = JSON.stringify(obj, null, 2);
pre.className = isOk ? "ok" : "err";
}} }}
async function showResponse(response) {{ async function showResponse(response) {{
const output = document.getElementById("output");
let data; let data;
try {{ try {{ data = await response.json(); }} catch {{ data = {{ detail: await response.text() }}; }}
data = await response.json(); setOutput({{ status: response.status, ok: response.ok, data }}, response.ok);
}} catch {{ }}
data = {{ detail: await response.text() }};
// ── Status pill ──────────────────────────────────────
async function refreshStatus() {{
try {{
const r = await fetch("status?" + commonParams().toString());
const d = await r.json();
const pill = document.getElementById("status-pill");
const txt = document.getElementById("status-text");
if (r.ok && d.ok) {{
pill.className = "status-pill ok";
txt.textContent = d.no_paper ? "No paper" : d.charging ? "Charging" : "Ready";
}} else {{
pill.className = "status-pill err";
txt.textContent = r.ok ? "Not ready" : "Not found";
}}
}} catch {{ /* ignore */ }}
}}
// auto-refresh status on load
window.addEventListener("DOMContentLoaded", refreshStatus);
// ── Scan ─────────────────────────────────────────────
async function scanAddress() {{
const btn = document.getElementById("scan-btn");
const icon = document.getElementById("scan-icon");
btn.disabled = true;
icon.outerHTML = '<span id="scan-icon" class="spinner"></span>';
setOutput({{ message: "Scanning for printer (up to 8 s)…" }}, true);
try {{
const r = await fetch("scan");
const d = await r.json();
if (r.ok && d.address) {{
document.getElementById("address").value = d.address;
}}
setOutput({{ status: r.status, ok: r.ok, data: d }}, r.ok);
}} catch (e) {{
setOutput({{ error: String(e) }}, false);
}} finally {{
btn.disabled = false;
document.getElementById("scan-icon").outerHTML = '<span id="scan-icon">⟳</span>';
}} }}
output.textContent = JSON.stringify({{ status: response.status, ok: response.ok, data }}, null, 2);
}} }}
async function runGet(path) {{ async function runGet(path) {{
const response = await fetch(`${{path}}?${{commonParams().toString()}}`); setOutput({{ message: "Loading…" }}, true);
await showResponse(response); const r = await fetch(path + "?" + commonParams().toString());
await showResponse(r);
}} }}
// ── Print text ───────────────────────────────────────
async function printText() {{ async function printText() {{
const form = new FormData(); const form = new FormData();
form.set("text", document.getElementById("text").value); form.set("text", document.getElementById("text").value);
@@ -363,18 +562,18 @@ def _ui_html() -> str:
form.set("address", document.getElementById("address").value.trim()); form.set("address", document.getElementById("address").value.trim());
form.set("classic", String(document.getElementById("transport").value === "classic")); form.set("classic", String(document.getElementById("transport").value === "classic"));
form.set("channel", document.getElementById("channel").value); form.set("channel", document.getElementById("channel").value);
const response = await fetch("print/text", {{ method: "POST", body: form }}); setOutput({{ message: "Sending to printer…" }}, true);
await showResponse(response); const r = await fetch("print/text", {{ method: "POST", body: form }});
await showResponse(r);
if (r.ok) refreshStatus();
}} }}
// ── Print image ──────────────────────────────────────
async function printImage() {{ async function printImage() {{
const fileInput = document.getElementById("image_file"); const fi = document.getElementById("image_file");
if (!fileInput.files.length) {{ if (!fi.files.length) {{ setOutput({{ error: "Select an image file first." }}, false); return; }}
document.getElementById("output").textContent = "Select an image file first.";
return;
}}
const form = new FormData(); const form = new FormData();
form.set("file", fileInput.files[0]); form.set("file", fi.files[0]);
form.set("density", document.getElementById("image_density").value); form.set("density", document.getElementById("image_density").value);
form.set("copies", document.getElementById("image_copies").value); form.set("copies", document.getElementById("image_copies").value);
form.set("label_length", document.getElementById("image_label_length").value); form.set("label_length", document.getElementById("image_label_length").value);
@@ -383,8 +582,10 @@ def _ui_html() -> str:
form.set("address", document.getElementById("address").value.trim()); form.set("address", document.getElementById("address").value.trim());
form.set("classic", String(document.getElementById("transport").value === "classic")); form.set("classic", String(document.getElementById("transport").value === "classic"));
form.set("channel", document.getElementById("channel").value); form.set("channel", document.getElementById("channel").value);
const response = await fetch("print/image", {{ method: "POST", body: form }}); setOutput({{ message: "Sending to printer…" }}, true);
await showResponse(response); const r = await fetch("print/image", {{ method: "POST", body: form }});
await showResponse(r);
if (r.ok) refreshStatus();
}} }}
</script> </script>
</body> </body>
@@ -410,6 +611,22 @@ async def docs():
) )
@app.get(
"/scan",
summary="Scan for printer",
response_description="BLE address of the discovered printer",
)
async def scan_printer():
"""Scan for a Fichero/D11s printer via BLE and return its address."""
try:
address = await find_printer()
except PrinterNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"address": address}
@app.get( @app.get(
"/status", "/status",
summary="Get printer status", summary="Get printer status",

View File

@@ -90,6 +90,23 @@ async def find_printer() -> str:
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?") raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
async def resolve_ble_target(address: str | None = None):
"""Resolve a BLE target as Bleak device object when possible.
Passing a discovered device object to BleakClient helps BlueZ keep the
correct LE context for dual-mode environments.
"""
if address:
device = await BleakScanner.find_device_by_address(address, timeout=8.0)
return device or address
devices = await BleakScanner.discover(timeout=8)
for d in devices:
if d.name and any(d.name.startswith(p) for p in PRINTER_NAME_PREFIXES):
print(f" Found {d.name} at {d.address}")
return d
raise PrinterNotFound("No Fichero/D11s printer found. Is it turned on?")
# --- Status --- # --- Status ---
@@ -406,19 +423,34 @@ async def connect(
) from last_exc ) from last_exc
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.") raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
else: else:
addr = address or await find_printer() target = await resolve_ble_target(address)
def _is_retryable_ble_error(exc: Exception) -> bool: def _is_retryable_ble_error(exc: Exception) -> bool:
msg = str(exc).lower() msg = str(exc).lower()
return any(token in msg for token in ("timeout", "timed out", "br-connection-timeout")) return any(
token in msg
for token in (
"timeout",
"timed out",
"br-connection-timeout",
"failed to discover services",
"device disconnected",
)
)
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(1, BLE_CONNECT_RETRIES + 1): for attempt in range(1, BLE_CONNECT_RETRIES + 1):
try: try:
async with BleakClient(addr) as client: async with BleakClient(target) as client:
pc = PrinterClient(client) pc = PrinterClient(client)
await pc.start() await pc.start()
yield pc yield pc
return return
except asyncio.TimeoutError as exc:
last_exc = exc
if attempt < BLE_CONNECT_RETRIES:
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
continue
raise PrinterError(f"BLE connection timed out: {exc}") from exc
except BleakDBusError as exc: except BleakDBusError as exc:
msg = str(exc).lower() msg = str(exc).lower()
if "br-connection-not-supported" in msg: if "br-connection-not-supported" in msg:

View File

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

View File

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

View File

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

View File

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

View File

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