refactor
This commit is contained in:
@@ -1,829 +0,0 @@
|
||||
"""HTTP REST API for the Fichero D11s thermal label printer.
|
||||
|
||||
Start with:
|
||||
fichero-server [--host HOST] [--port PORT]
|
||||
or:
|
||||
python -m fichero.api
|
||||
|
||||
Endpoints:
|
||||
GET /status – Printer status
|
||||
GET /info – Printer info (model, firmware, battery, …)
|
||||
POST /print/text – Print a text label
|
||||
POST /print/image – Print an uploaded image file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from PIL import Image
|
||||
|
||||
from fichero.cli import DOTS_PER_MM, do_print
|
||||
from fichero.imaging import text_to_image
|
||||
from fichero.printer import (
|
||||
PAPER_GAP,
|
||||
PrinterError,
|
||||
PrinterNotFound,
|
||||
PrinterTimeout,
|
||||
connect,
|
||||
find_printer,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global connection settings (env vars or CLI flags at startup)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_ADDRESS: str | None = os.environ.get("FICHERO_ADDR")
|
||||
_DEFAULT_CLASSIC: bool = os.environ.get("FICHERO_TRANSPORT", "").lower() == "classic"
|
||||
_DEFAULT_CHANNEL: int = int(os.environ.get("FICHERO_CHANNEL", "1"))
|
||||
|
||||
_PAPER_MAP = {"gap": 0, "black": 1, "continuous": 2}
|
||||
|
||||
|
||||
def _parse_paper(value: str) -> int:
|
||||
if value in _PAPER_MAP:
|
||||
return _PAPER_MAP[value]
|
||||
try:
|
||||
val = int(value)
|
||||
if 0 <= val <= 2:
|
||||
return val
|
||||
except ValueError:
|
||||
pass
|
||||
raise HTTPException(status_code=422, detail=f"Invalid paper type '{value}'. Use gap, black, continuous or 0-2.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # noqa: ARG001
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Fichero Printer API",
|
||||
description="REST API for the Fichero D11s (AiYin) thermal label printer.",
|
||||
version="0.1.13",
|
||||
lifespan=lifespan,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def _address(address: str | None) -> str | None:
|
||||
"""Return the effective BLE address (request value overrides env default)."""
|
||||
return address or _DEFAULT_ADDRESS
|
||||
|
||||
|
||||
def _ui_html() -> str:
|
||||
default_address = _DEFAULT_ADDRESS or ""
|
||||
default_transport = "classic" if _DEFAULT_CLASSIC else "ble"
|
||||
return f"""<!doctype html>
|
||||
<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 {{
|
||||
--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;
|
||||
}}
|
||||
|
||||
html {{ height: 100%; }}
|
||||
body {{
|
||||
min-height: 100dvh;
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
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: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 60px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||||
}}
|
||||
main > .full {{ grid-column: 1 / -1; }}
|
||||
|
||||
/* ── Cards ──────────────────────────────────────── */
|
||||
.card {{
|
||||
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;
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 5px;
|
||||
}}
|
||||
label:first-child {{ margin-top: 0; }}
|
||||
input, select, textarea {{
|
||||
width: 100%;
|
||||
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;
|
||||
}}
|
||||
input:focus, select:focus, textarea:focus {{
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(7,173,223,.2);
|
||||
}}
|
||||
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;
|
||||
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;
|
||||
}}
|
||||
.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;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}}
|
||||
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;
|
||||
}}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────── */
|
||||
footer {{
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: .72rem;
|
||||
color: var(--dim);
|
||||
}}
|
||||
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>
|
||||
|
||||
<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">
|
||||
<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="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 BT</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="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">
|
||||
<p class="card-title">Response</p>
|
||||
<pre id="output">Waiting for a command…</pre>
|
||||
</div>
|
||||
|
||||
<!-- Print text card -->
|
||||
<div class="card">
|
||||
<p class="card-title">Print text</p>
|
||||
|
||||
<label for="text">Text</label>
|
||||
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
|
||||
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<label for="text_density">Density</label>
|
||||
<select id="text_density">
|
||||
<option value="0">0 – Light</option>
|
||||
<option value="1">1 – Medium</option>
|
||||
<option value="2" selected>2 – Dark</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="two-col">
|
||||
<div>
|
||||
<label for="text_font_size">Font size (pt)</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 type</label>
|
||||
<select id="text_paper">
|
||||
<option value="gap" selected>Gap / label</option>
|
||||
<option value="black">Black mark</option>
|
||||
<option value="continuous">Continuous</option>
|
||||
</select>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="printText()">🖨 Print text</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print image card -->
|
||||
<div class="card">
|
||||
<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="two-col">
|
||||
<div>
|
||||
<label for="image_density">Density</label>
|
||||
<select id="image_density">
|
||||
<option value="0">0 – Light</option>
|
||||
<option value="1">1 – Medium</option>
|
||||
<option value="2" selected>2 – Dark</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="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>
|
||||
<label for="image_paper">Paper type</label>
|
||||
<select id="image_paper">
|
||||
<option value="gap" selected>Gap / label</option>
|
||||
<option value="black">Black mark</option>
|
||||
<option value="continuous">Continuous</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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) {{
|
||||
let data;
|
||||
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>';
|
||||
}}
|
||||
}}
|
||||
|
||||
async function runGet(path) {{
|
||||
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);
|
||||
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);
|
||||
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 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", 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);
|
||||
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);
|
||||
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>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/", include_in_schema=False, response_class=HTMLResponse)
|
||||
async def root():
|
||||
"""Serve a compact printer UI for Home Assistant."""
|
||||
return HTMLResponse(_ui_html())
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def docs():
|
||||
"""Serve Swagger UI with ingress-safe relative OpenAPI URL."""
|
||||
return get_swagger_ui_html(
|
||||
openapi_url="openapi.json",
|
||||
title=f"{app.title} - Swagger UI",
|
||||
)
|
||||
|
||||
|
||||
@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(
|
||||
"/status",
|
||||
summary="Get printer status",
|
||||
response_description="Current printer status flags",
|
||||
)
|
||||
async def get_status(
|
||||
address: str | None = None,
|
||||
classic: bool = _DEFAULT_CLASSIC,
|
||||
channel: int = _DEFAULT_CHANNEL,
|
||||
):
|
||||
"""Return the real-time status of the printer (paper, battery, heat, …)."""
|
||||
try:
|
||||
async with connect(_address(address), classic=classic, channel=channel) as pc:
|
||||
status = await pc.get_status()
|
||||
except PrinterNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PrinterTimeout as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except PrinterError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"ok": status.ok,
|
||||
"printing": status.printing,
|
||||
"cover_open": status.cover_open,
|
||||
"no_paper": status.no_paper,
|
||||
"low_battery": status.low_battery,
|
||||
"overheated": status.overheated,
|
||||
"charging": status.charging,
|
||||
"raw": status.raw,
|
||||
}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/info",
|
||||
summary="Get printer info",
|
||||
response_description="Model, firmware, serial number and battery level",
|
||||
)
|
||||
async def get_info(
|
||||
address: str | None = None,
|
||||
classic: bool = _DEFAULT_CLASSIC,
|
||||
channel: int = _DEFAULT_CHANNEL,
|
||||
):
|
||||
"""Return static and dynamic printer information."""
|
||||
try:
|
||||
async with connect(_address(address), classic=classic, channel=channel) as pc:
|
||||
info = await pc.get_info()
|
||||
info.update(await pc.get_all_info())
|
||||
except PrinterNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PrinterTimeout as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except PrinterError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@app.post(
|
||||
"/print/text",
|
||||
summary="Print a text label",
|
||||
status_code=200,
|
||||
)
|
||||
async def print_text(
|
||||
text: Annotated[str, Form(description="Text to print on the label")],
|
||||
density: Annotated[int, Form(description="Print density: 0=light, 1=medium, 2=dark", ge=0, le=2)] = 2,
|
||||
paper: Annotated[str, Form(description="Paper type: gap, black, or continuous")] = "gap",
|
||||
copies: Annotated[int, Form(description="Number of copies", ge=1, le=99)] = 1,
|
||||
font_size: Annotated[int, Form(description="Font size in points", ge=6, le=200)] = 30,
|
||||
label_length: Annotated[int | None, Form(description="Label length in mm (overrides label_height)", ge=5, le=500)] = None,
|
||||
label_height: Annotated[int, Form(description="Label height in pixels", ge=40, le=4000)] = 240,
|
||||
address: Annotated[str | None, Form(description="BLE address (optional, overrides FICHERO_ADDR)")] = None,
|
||||
classic: Annotated[bool, Form(description="Use Classic Bluetooth RFCOMM")] = _DEFAULT_CLASSIC,
|
||||
channel: Annotated[int, Form(description="RFCOMM channel")] = _DEFAULT_CHANNEL,
|
||||
):
|
||||
"""Print a plain-text label.
|
||||
|
||||
The text is rendered as a 96 px wide, 1-bit image and sent to the printer.
|
||||
"""
|
||||
paper_val = _parse_paper(paper)
|
||||
max_rows = (label_length * DOTS_PER_MM) if label_length is not None else label_height
|
||||
|
||||
img = text_to_image(text, font_size=font_size, label_height=max_rows)
|
||||
|
||||
try:
|
||||
async with connect(_address(address), classic=classic, channel=channel) as pc:
|
||||
ok = await do_print(pc, img, density=density, paper=paper_val, copies=copies,
|
||||
dither=False, max_rows=max_rows)
|
||||
except PrinterNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PrinterTimeout as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except PrinterError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
if not ok:
|
||||
raise HTTPException(status_code=502, detail="Printer did not confirm completion.")
|
||||
|
||||
return {"ok": True, "copies": copies, "text": text}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/print/image",
|
||||
summary="Print an image",
|
||||
status_code=200,
|
||||
)
|
||||
async def print_image(
|
||||
file: Annotated[UploadFile, File(description="Image file to print (PNG, JPEG, BMP, …)")],
|
||||
density: Annotated[int, Form(description="Print density: 0=light, 1=medium, 2=dark", ge=0, le=2)] = 2,
|
||||
paper: Annotated[str, Form(description="Paper type: gap, black, or continuous")] = "gap",
|
||||
copies: Annotated[int, Form(description="Number of copies", ge=1, le=99)] = 1,
|
||||
dither: Annotated[bool, Form(description="Apply Floyd-Steinberg dithering")] = True,
|
||||
label_length: Annotated[int | None, Form(description="Max label length in mm (overrides label_height)", ge=5, le=500)] = None,
|
||||
label_height: Annotated[int, Form(description="Max label height in pixels", ge=40, le=4000)] = 240,
|
||||
address: Annotated[str | None, Form(description="BLE address (optional, overrides FICHERO_ADDR)")] = None,
|
||||
classic: Annotated[bool, Form(description="Use Classic Bluetooth RFCOMM")] = _DEFAULT_CLASSIC,
|
||||
channel: Annotated[int, Form(description="RFCOMM channel")] = _DEFAULT_CHANNEL,
|
||||
):
|
||||
"""Print an image file.
|
||||
|
||||
The image is resized to 96 px wide, optionally dithered to 1-bit, and sent to the printer.
|
||||
Supported formats: PNG, JPEG, BMP, GIF, TIFF, WEBP.
|
||||
"""
|
||||
# Validate content type loosely — Pillow will raise on unsupported data
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(status_code=422, detail="Uploaded file is empty.")
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load() # ensure the image is fully decoded
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Cannot decode image: {exc}") from exc
|
||||
|
||||
paper_val = _parse_paper(paper)
|
||||
max_rows = (label_length * DOTS_PER_MM) if label_length is not None else label_height
|
||||
|
||||
try:
|
||||
async with connect(_address(address), classic=classic, channel=channel) as pc:
|
||||
ok = await do_print(pc, img, density=density, paper=paper_val, copies=copies,
|
||||
dither=dither, max_rows=max_rows)
|
||||
except PrinterNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PrinterTimeout as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except PrinterError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
if not ok:
|
||||
raise HTTPException(status_code=502, detail="Printer did not confirm completion.")
|
||||
|
||||
return {"ok": True, "copies": copies, "filename": file.filename}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
"""Start the Fichero HTTP API server."""
|
||||
global _DEFAULT_ADDRESS, _DEFAULT_CLASSIC, _DEFAULT_CHANNEL
|
||||
|
||||
try:
|
||||
import uvicorn # noqa: PLC0415
|
||||
except ImportError:
|
||||
print("ERROR: uvicorn is required to run the API server.")
|
||||
print("Install it with: pip install 'fichero-printer[api]'")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
parser = argparse.ArgumentParser(description="Fichero Printer API Server")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)")
|
||||
parser.add_argument("--address", default=_DEFAULT_ADDRESS, metavar="BLE_ADDR",
|
||||
help="Default BLE address (or set FICHERO_ADDR env var)")
|
||||
parser.add_argument("--classic", action="store_true", default=_DEFAULT_CLASSIC,
|
||||
help="Default to Classic Bluetooth RFCOMM")
|
||||
parser.add_argument("--channel", type=int, default=_DEFAULT_CHANNEL,
|
||||
help="Default RFCOMM channel (default: 1)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Push CLI overrides into module-level defaults so all handlers pick them up
|
||||
_DEFAULT_ADDRESS = args.address
|
||||
_DEFAULT_CLASSIC = args.classic
|
||||
_DEFAULT_CHANNEL = args.channel
|
||||
|
||||
# Pass the app object directly when not reloading so that the module-level
|
||||
# globals (_DEFAULT_ADDRESS etc.) set above are visible to the handlers.
|
||||
# The string form "fichero.api:app" is required for --reload only, because
|
||||
# uvicorn's reloader needs to re-import the module in a worker process.
|
||||
uvicorn.run(
|
||||
"fichero.api:app" if args.reload else app,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=args.reload,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user