Add HA print web UI and release version 0.1.8
This commit is contained in:
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
The format is based on Keep a Changelog and this project uses Semantic Versioning.
|
||||||
|
|
||||||
|
## [0.1.8] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Root URL now serves a built-in printer web interface for Home Assistant with status, info, text printing, and image upload printing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Swagger docs remain available under `/docs` while the Home Assistant "Open" action now lands on the print UI.
|
||||||
|
|
||||||
|
## [0.1.7] - 2026-03-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Home Assistant ingress docs now use a custom Swagger UI route with a relative `openapi.json` URL, avoiding `404 /openapi.json` behind ingress prefixes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Home Assistant add-on now requests `full_access: true` in addition to Bluetooth capabilities to unblock Classic RFCOMM socket access on stricter hosts.
|
||||||
|
|
||||||
|
## [0.1.6] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added this `CHANGELOG.md` and established a release policy to update version and changelog for every change.
|
||||||
|
|
||||||
|
## [0.1.5] - 2026-03-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Home Assistant add-on now requests `NET_RAW` in addition to `NET_ADMIN` for Classic Bluetooth RFCOMM sockets.
|
||||||
|
- Add-on documentation updated with Classic permission requirements.
|
||||||
|
|
||||||
|
## [0.1.4] - 2026-03-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- RFCOMM connection under `uvloop` now uses direct Bluetooth socket connect in a worker thread, avoiding address-family resolution issues.
|
||||||
|
- Classic Bluetooth socket errors are mapped to API-safe printer errors instead of unhandled 500s.
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-03-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Home Assistant add-on metadata updated for ingress/web UI access.
|
||||||
|
- API root endpoint now redirects to docs in an ingress-compatible way.
|
||||||
|
- Added attribution for original upstream project and AI-assisted extension note.
|
||||||
@@ -7,6 +7,11 @@ Web GUI, Python CLI, and protocol documentation for the Fichero D11s thermal lab
|
|||||||
- Original developer/project: [0xMH/fichero-printer](https://github.com/0xMH/fichero-printer)
|
- Original developer/project: [0xMH/fichero-printer](https://github.com/0xMH/fichero-printer)
|
||||||
- This repository version was additionally extended with AI-assisted changes.
|
- This repository version was additionally extended with AI-assisted changes.
|
||||||
|
|
||||||
|
## Release Policy
|
||||||
|
|
||||||
|
- Maintain `CHANGELOG.md` for every user-visible change.
|
||||||
|
- Bump the project/add-on version with every merged change.
|
||||||
|
|
||||||
Blog post: [Reverse Engineering Action's Cheap Fichero Labelprinter](https://blog.dbuglife.com/reverse-engineering-fichero-label-printer/)
|
Blog post: [Reverse Engineering Action's Cheap Fichero Labelprinter](https://blog.dbuglife.com/reverse-engineering-fichero-label-printer/)
|
||||||
|
|
||||||
The [Fichero](https://www.action.com/nl-nl/p/3212141/fichero-labelprinter/) is a cheap Bluetooth thermal label printer sold at Action. Internally it's an AiYin D11s made by Xiamen Print Future Technology. The official app is closed-source and doesn't expose the protocol, so this project reverse-engineers it from the decompiled APK.
|
The [Fichero](https://www.action.com/nl-nl/p/3212141/fichero-labelprinter/) is a cheap Bluetooth thermal label printer sold at Action. Internally it's an AiYin D11s made by Xiamen Print Future Technology. The official app is closed-source and doesn't expose the protocol, so this project reverse-engineers it from the decompiled APK.
|
||||||
|
|||||||
320
fichero/api.py
320
fichero/api.py
@@ -23,7 +23,8 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from fichero.cli import DOTS_PER_MM, do_print
|
from fichero.cli import DOTS_PER_MM, do_print
|
||||||
@@ -73,6 +74,8 @@ app = FastAPI(
|
|||||||
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.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -88,14 +91,323 @@ def _address(address: str | None) -> str | None:
|
|||||||
return address or _DEFAULT_ADDRESS
|
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">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fichero Printer</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--panel: #fffaf2;
|
||||||
|
--line: #d8cdbd;
|
||||||
|
--ink: #2d241d;
|
||||||
|
--muted: #6c6258;
|
||||||
|
--accent: #b55e33;
|
||||||
|
--accent-2: #245b4b;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Noto Sans", system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, #fff8ed 0, transparent 35%),
|
||||||
|
linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%);
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 40px;
|
||||||
|
}}
|
||||||
|
.hero {{
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 250, 242, 0.92);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}}
|
||||||
|
h1, h2 {{ margin: 0 0 12px; }}
|
||||||
|
.muted {{ color: var(--muted); }}
|
||||||
|
.grid {{
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06);
|
||||||
|
}}
|
||||||
|
label {{
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
input, select, textarea, button {{
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}}
|
||||||
|
textarea {{ min-height: 110px; resize: vertical; }}
|
||||||
|
.row {{
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}}
|
||||||
|
.inline {{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
.inline input[type="checkbox"] {{ width: auto; }}
|
||||||
|
button {{
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
button.alt {{ background: var(--accent-2); }}
|
||||||
|
pre {{
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #241f1a;
|
||||||
|
color: #f7efe4;
|
||||||
|
min-height: 140px;
|
||||||
|
}}
|
||||||
|
.actions {{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
.actions button {{
|
||||||
|
width: auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}}
|
||||||
|
@media (max-width: 640px) {{
|
||||||
|
.row {{ grid-template-columns: 1fr; }}
|
||||||
|
.actions button {{ width: 100%; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Fichero Printer</h1>
|
||||||
|
<p class="muted">Home Assistant print console for status, text labels, and image uploads.</p>
|
||||||
|
<p class="muted">API docs remain available at <a href="docs">/docs</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Connection</h2>
|
||||||
|
<label for="address">Printer address</label>
|
||||||
|
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="transport">Transport</label>
|
||||||
|
<select id="transport">
|
||||||
|
<option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option>
|
||||||
|
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="channel">RFCOMM channel</label>
|
||||||
|
<input id="channel" type="number" min="1" max="30" value="{_DEFAULT_CHANNEL}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="alt" onclick="runGet('status')">Get Status</button>
|
||||||
|
<button type="button" class="alt" onclick="runGet('info')">Get Info</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Output</h2>
|
||||||
|
<pre id="output">Ready.</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Print Text</h2>
|
||||||
|
<label for="text">Text</label>
|
||||||
|
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="text_density">Density</label>
|
||||||
|
<select id="text_density">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="text_copies">Copies</label>
|
||||||
|
<input id="text_copies" type="number" min="1" max="99" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="text_font_size">Font size</label>
|
||||||
|
<input id="text_font_size" type="number" min="6" max="200" value="30">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="text_label_length">Label length (mm)</label>
|
||||||
|
<input id="text_label_length" type="number" min="5" max="500" value="30">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="text_paper">Paper</label>
|
||||||
|
<select id="text_paper">
|
||||||
|
<option value="gap" selected>gap</option>
|
||||||
|
<option value="black">black</option>
|
||||||
|
<option value="continuous">continuous</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="printText()">Print Text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Print Image</h2>
|
||||||
|
<label for="image_file">Image file</label>
|
||||||
|
<input id="image_file" type="file" accept="image/*">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="image_density">Density</label>
|
||||||
|
<select id="image_density">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="image_copies">Copies</label>
|
||||||
|
<input id="image_copies" type="number" min="1" max="99" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="image_label_length">Label length (mm)</label>
|
||||||
|
<input id="image_label_length" type="number" min="5" max="500" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="inline">
|
||||||
|
<input id="image_dither" type="checkbox" checked>
|
||||||
|
<label for="image_dither">Enable dithering</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="image_paper">Paper</label>
|
||||||
|
<select id="image_paper">
|
||||||
|
<option value="gap" selected>gap</option>
|
||||||
|
<option value="black">black</option>
|
||||||
|
<option value="continuous">continuous</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="printImage()">Print Image</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function commonParams() {{
|
||||||
|
const address = document.getElementById("address").value.trim();
|
||||||
|
const classic = document.getElementById("transport").value === "classic";
|
||||||
|
const channel = document.getElementById("channel").value;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (address) params.set("address", address);
|
||||||
|
params.set("classic", String(classic));
|
||||||
|
params.set("channel", channel);
|
||||||
|
return params;
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function showResponse(response) {{
|
||||||
|
const output = document.getElementById("output");
|
||||||
|
let data;
|
||||||
|
try {{
|
||||||
|
data = await response.json();
|
||||||
|
}} catch {{
|
||||||
|
data = {{ detail: await response.text() }};
|
||||||
|
}}
|
||||||
|
output.textContent = JSON.stringify({{ status: response.status, ok: response.ok, data }}, null, 2);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function runGet(path) {{
|
||||||
|
const response = await fetch(`${{path}}?${{commonParams().toString()}}`);
|
||||||
|
await showResponse(response);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function printText() {{
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("text", document.getElementById("text").value);
|
||||||
|
form.set("density", document.getElementById("text_density").value);
|
||||||
|
form.set("copies", document.getElementById("text_copies").value);
|
||||||
|
form.set("font_size", document.getElementById("text_font_size").value);
|
||||||
|
form.set("label_length", document.getElementById("text_label_length").value);
|
||||||
|
form.set("paper", document.getElementById("text_paper").value);
|
||||||
|
form.set("address", document.getElementById("address").value.trim());
|
||||||
|
form.set("classic", String(document.getElementById("transport").value === "classic"));
|
||||||
|
form.set("channel", document.getElementById("channel").value);
|
||||||
|
const response = await fetch("print/text", {{ method: "POST", body: form }});
|
||||||
|
await showResponse(response);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function printImage() {{
|
||||||
|
const fileInput = document.getElementById("image_file");
|
||||||
|
if (!fileInput.files.length) {{
|
||||||
|
document.getElementById("output").textContent = "Select an image file first.";
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", fileInput.files[0]);
|
||||||
|
form.set("density", document.getElementById("image_density").value);
|
||||||
|
form.set("copies", document.getElementById("image_copies").value);
|
||||||
|
form.set("label_length", document.getElementById("image_label_length").value);
|
||||||
|
form.set("paper", document.getElementById("image_paper").value);
|
||||||
|
form.set("dither", String(document.getElementById("image_dither").checked));
|
||||||
|
form.set("address", document.getElementById("address").value.trim());
|
||||||
|
form.set("classic", String(document.getElementById("transport").value === "classic"));
|
||||||
|
form.set("channel", document.getElementById("channel").value);
|
||||||
|
const response = await fetch("print/image", {{ method: "POST", body: form }});
|
||||||
|
await showResponse(response);
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False, response_class=HTMLResponse)
|
||||||
async def root():
|
async def root():
|
||||||
"""Redirect root to interactive API docs."""
|
"""Serve a compact printer UI for Home Assistant."""
|
||||||
return RedirectResponse(url="docs")
|
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(
|
@app.get(
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen.
|
|||||||
|
|
||||||
Das Add-on ist nach dem Start auf zwei Arten erreichbar:
|
Das Add-on ist nach dem Start auf zwei Arten erreichbar:
|
||||||
|
|
||||||
1. Home Assistant UI (Ingress): In der Add-on-Seite auf **"Öffnen"** klicken.
|
1. Home Assistant UI (Ingress): In der Add-on-Seite auf **"Öffnen"** klicken. Dort erscheint direkt das Webinterface zum Abrufen von Status/Info sowie zum Drucken von Text und Bildern.
|
||||||
2. Direkt per Port im Netzwerk: `http://<HA-IP>:<port>` (z.B. `http://homeassistant.local:8765`).
|
2. Direkt per Port im Netzwerk: `http://<HA-IP>:<port>` (z.B. `http://homeassistant.local:8765`).
|
||||||
|
|
||||||
Hinweis: `/` leitet auf `/docs` weiter (Swagger UI).
|
Hinweis: Die API-Dokumentation bleibt unter `/docs` erreichbar.
|
||||||
|
|
||||||
### Endpunkte
|
### Endpunkte
|
||||||
|
|
||||||
@@ -148,6 +148,8 @@ rest_command:
|
|||||||
- **Classic Bluetooth (RFCOMM):** Nur unter Linux verfügbar. Erfordert die
|
- **Classic Bluetooth (RFCOMM):** Nur unter Linux verfügbar. Erfordert die
|
||||||
direkte Bluetooth-Adresse (kein automatischer Scan möglich) und Container-
|
direkte Bluetooth-Adresse (kein automatischer Scan möglich) und Container-
|
||||||
Rechte für Bluetooth-Sockets (`NET_ADMIN` + `NET_RAW`).
|
Rechte für Bluetooth-Sockets (`NET_ADMIN` + `NET_RAW`).
|
||||||
|
- Das Add-on läuft dafür mit `full_access`, weil einige Home-Assistant-Hosts
|
||||||
|
RFCOMM trotz gesetzter Capabilities sonst weiterhin blockieren.
|
||||||
- Wenn die BLE-Adresse bekannt ist, diese in der Konfiguration eintragen –
|
- Wenn die BLE-Adresse bekannt ist, diese in der Konfiguration eintragen –
|
||||||
das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig).
|
das beschleunigt den Verbindungsaufbau erheblich (kein Scan nötig).
|
||||||
- Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird.
|
- Der Drucker muss eingeschaltet sein, bevor eine Anfrage gestellt wird.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: "Fichero Printer"
|
name: "Fichero Printer"
|
||||||
version: "0.1.5"
|
version: "0.1.8"
|
||||||
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"
|
||||||
@@ -19,6 +19,7 @@ ingress_port: 8765
|
|||||||
panel_icon: mdi:printer
|
panel_icon: mdi:printer
|
||||||
panel_title: Fichero Printer
|
panel_title: Fichero Printer
|
||||||
webui: "http://[HOST]:[PORT:8765]/"
|
webui: "http://[HOST]:[PORT:8765]/"
|
||||||
|
full_access: true
|
||||||
|
|
||||||
host_network: true
|
host_network: true
|
||||||
host_dbus: true
|
host_dbus: true
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from fichero.cli import DOTS_PER_MM, do_print
|
from fichero.cli import DOTS_PER_MM, do_print
|
||||||
@@ -73,6 +74,8 @@ app = FastAPI(
|
|||||||
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.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -88,14 +91,323 @@ def _address(address: str | None) -> str | None:
|
|||||||
return address or _DEFAULT_ADDRESS
|
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">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fichero Printer</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--panel: #fffaf2;
|
||||||
|
--line: #d8cdbd;
|
||||||
|
--ink: #2d241d;
|
||||||
|
--muted: #6c6258;
|
||||||
|
--accent: #b55e33;
|
||||||
|
--accent-2: #245b4b;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Noto Sans", system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, #fff8ed 0, transparent 35%),
|
||||||
|
linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%);
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 40px;
|
||||||
|
}}
|
||||||
|
.hero {{
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 250, 242, 0.92);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}}
|
||||||
|
h1, h2 {{ margin: 0 0 12px; }}
|
||||||
|
.muted {{ color: var(--muted); }}
|
||||||
|
.grid {{
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06);
|
||||||
|
}}
|
||||||
|
label {{
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
input, select, textarea, button {{
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}}
|
||||||
|
textarea {{ min-height: 110px; resize: vertical; }}
|
||||||
|
.row {{
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}}
|
||||||
|
.inline {{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
.inline input[type="checkbox"] {{ width: auto; }}
|
||||||
|
button {{
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
button.alt {{ background: var(--accent-2); }}
|
||||||
|
pre {{
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #241f1a;
|
||||||
|
color: #f7efe4;
|
||||||
|
min-height: 140px;
|
||||||
|
}}
|
||||||
|
.actions {{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
.actions button {{
|
||||||
|
width: auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}}
|
||||||
|
@media (max-width: 640px) {{
|
||||||
|
.row {{ grid-template-columns: 1fr; }}
|
||||||
|
.actions button {{ width: 100%; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Fichero Printer</h1>
|
||||||
|
<p class="muted">Home Assistant print console for status, text labels, and image uploads.</p>
|
||||||
|
<p class="muted">API docs remain available at <a href="docs">/docs</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Connection</h2>
|
||||||
|
<label for="address">Printer address</label>
|
||||||
|
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="transport">Transport</label>
|
||||||
|
<select id="transport">
|
||||||
|
<option value="ble"{" selected" if default_transport == "ble" else ""}>BLE</option>
|
||||||
|
<option value="classic"{" selected" if default_transport == "classic" else ""}>Classic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="channel">RFCOMM channel</label>
|
||||||
|
<input id="channel" type="number" min="1" max="30" value="{_DEFAULT_CHANNEL}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="alt" onclick="runGet('status')">Get Status</button>
|
||||||
|
<button type="button" class="alt" onclick="runGet('info')">Get Info</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Output</h2>
|
||||||
|
<pre id="output">Ready.</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Print Text</h2>
|
||||||
|
<label for="text">Text</label>
|
||||||
|
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="text_density">Density</label>
|
||||||
|
<select id="text_density">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="text_copies">Copies</label>
|
||||||
|
<input id="text_copies" type="number" min="1" max="99" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="text_font_size">Font size</label>
|
||||||
|
<input id="text_font_size" type="number" min="6" max="200" value="30">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="text_label_length">Label length (mm)</label>
|
||||||
|
<input id="text_label_length" type="number" min="5" max="500" value="30">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="text_paper">Paper</label>
|
||||||
|
<select id="text_paper">
|
||||||
|
<option value="gap" selected>gap</option>
|
||||||
|
<option value="black">black</option>
|
||||||
|
<option value="continuous">continuous</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="printText()">Print Text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Print Image</h2>
|
||||||
|
<label for="image_file">Image file</label>
|
||||||
|
<input id="image_file" type="file" accept="image/*">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="image_density">Density</label>
|
||||||
|
<select id="image_density">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="image_copies">Copies</label>
|
||||||
|
<input id="image_copies" type="number" min="1" max="99" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="image_label_length">Label length (mm)</label>
|
||||||
|
<input id="image_label_length" type="number" min="5" max="500" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="inline">
|
||||||
|
<input id="image_dither" type="checkbox" checked>
|
||||||
|
<label for="image_dither">Enable dithering</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="image_paper">Paper</label>
|
||||||
|
<select id="image_paper">
|
||||||
|
<option value="gap" selected>gap</option>
|
||||||
|
<option value="black">black</option>
|
||||||
|
<option value="continuous">continuous</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="printImage()">Print Image</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function commonParams() {{
|
||||||
|
const address = document.getElementById("address").value.trim();
|
||||||
|
const classic = document.getElementById("transport").value === "classic";
|
||||||
|
const channel = document.getElementById("channel").value;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (address) params.set("address", address);
|
||||||
|
params.set("classic", String(classic));
|
||||||
|
params.set("channel", channel);
|
||||||
|
return params;
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function showResponse(response) {{
|
||||||
|
const output = document.getElementById("output");
|
||||||
|
let data;
|
||||||
|
try {{
|
||||||
|
data = await response.json();
|
||||||
|
}} catch {{
|
||||||
|
data = {{ detail: await response.text() }};
|
||||||
|
}}
|
||||||
|
output.textContent = JSON.stringify({{ status: response.status, ok: response.ok, data }}, null, 2);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function runGet(path) {{
|
||||||
|
const response = await fetch(`${{path}}?${{commonParams().toString()}}`);
|
||||||
|
await showResponse(response);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function printText() {{
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("text", document.getElementById("text").value);
|
||||||
|
form.set("density", document.getElementById("text_density").value);
|
||||||
|
form.set("copies", document.getElementById("text_copies").value);
|
||||||
|
form.set("font_size", document.getElementById("text_font_size").value);
|
||||||
|
form.set("label_length", document.getElementById("text_label_length").value);
|
||||||
|
form.set("paper", document.getElementById("text_paper").value);
|
||||||
|
form.set("address", document.getElementById("address").value.trim());
|
||||||
|
form.set("classic", String(document.getElementById("transport").value === "classic"));
|
||||||
|
form.set("channel", document.getElementById("channel").value);
|
||||||
|
const response = await fetch("print/text", {{ method: "POST", body: form }});
|
||||||
|
await showResponse(response);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function printImage() {{
|
||||||
|
const fileInput = document.getElementById("image_file");
|
||||||
|
if (!fileInput.files.length) {{
|
||||||
|
document.getElementById("output").textContent = "Select an image file first.";
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", fileInput.files[0]);
|
||||||
|
form.set("density", document.getElementById("image_density").value);
|
||||||
|
form.set("copies", document.getElementById("image_copies").value);
|
||||||
|
form.set("label_length", document.getElementById("image_label_length").value);
|
||||||
|
form.set("paper", document.getElementById("image_paper").value);
|
||||||
|
form.set("dither", String(document.getElementById("image_dither").checked));
|
||||||
|
form.set("address", document.getElementById("address").value.trim());
|
||||||
|
form.set("classic", String(document.getElementById("transport").value === "classic"));
|
||||||
|
form.set("channel", document.getElementById("channel").value);
|
||||||
|
const response = await fetch("print/image", {{ method: "POST", body: form }});
|
||||||
|
await showResponse(response);
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False, response_class=HTMLResponse)
|
||||||
async def root():
|
async def root():
|
||||||
"""Redirect root to interactive API docs."""
|
"""Serve a compact printer UI for Home Assistant."""
|
||||||
return RedirectResponse(url="docs")
|
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(
|
@app.get(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fichero-printer"
|
name = "fichero-printer"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user