Compare commits
22 Commits
265cded661
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16886bfa21 | ||
|
|
8520a88197 | ||
|
|
1a51ebb122 | ||
|
|
92a7224774 | ||
| 9f191b564a | |||
|
|
42e56e1b9f | ||
| 7778a6f614 | |||
| 9d77fbe366 | |||
|
|
6b6d57bd77 | ||
|
|
822dbd35b2 | ||
| 45d945a9d4 | |||
| 7317a60818 | |||
|
|
8513afe831 | ||
| 4dd04d1d34 | |||
|
|
54ba6795c0 | ||
|
|
081883c823 | ||
|
|
8c00001d68 | ||
|
|
aa125736f3 | ||
| cb471b1cc8 | |||
|
|
3356909982 | ||
| 440b1c278a | |||
|
|
99c2fb79d7 |
113
CHANGELOG.md
Normal file
113
CHANGELOG.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# 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.20] - 2026-03-08
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the embedded web UI in the API server to be loaded from a separate `index.html` file instead of a large inline string, improving maintainability.
|
||||||
|
|
||||||
|
## [0.1.19] - 2026-03-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `POST /unpair` endpoint and "Unpair Device" button in the web UI to remove a Bluetooth device from the host's paired devices.
|
||||||
|
|
||||||
|
## [0.1.18] - 2026-03-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `POST /pair` endpoint and "Pair Device" button in the web UI to easily pair/trust the printer via `bluetoothctl` for Classic Bluetooth connections.
|
||||||
|
|
||||||
|
## [0.1.17] - 2026-03-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added automatic fallback to BLE connection if Classic Bluetooth (RFCOMM) fails with `[Errno 12] Out of memory`, a common issue on Linux with stale device states.
|
||||||
|
|
||||||
|
## [0.1.16] - 2026-03-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Corrected typos in the Code128B bit pattern table for characters '$' (ASCII 36) and ')' (ASCII 41), which caused incorrect barcodes to be generated.
|
||||||
|
|
||||||
|
## [0.1.15] - 2026-03-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Added BLE recovery path for `br-connection-not-supported`: the connector now forces a fresh LE scan target resolution and retries before returning an error.
|
||||||
|
|
||||||
|
## [0.1.14] - 2026-03-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed BLE fallback to raw MAC string when device resolution fails. The connector now requires a discovered LE device object, avoiding BlueZ BR/EDR misclassification that can cause `br-connection-not-supported`.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Added automatic BLE reconnect retry with linear backoff for transient timeout errors (including `br-connection-timeout`) before returning a failure.
|
||||||
|
|
||||||
|
## [0.1.9] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added add-on-local changelog at `fichero_printer/CHANGELOG.md` so Home Assistant can display release notes in the add-on UI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved Classic Bluetooth connect logic by trying fallback RFCOMM channels (1-3 plus configured channel) before failing.
|
||||||
|
|
||||||
|
## [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.
|
||||||
17
README.md
17
README.md
@@ -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.
|
||||||
@@ -135,6 +140,18 @@ asyncio.run(main())
|
|||||||
|
|
||||||
The package exports `PrinterClient`, `connect`, `PrinterError`, `PrinterNotFound`, `PrinterTimeout`, `PrinterNotReady`, and `PrinterStatus`.
|
The package exports `PrinterClient`, `connect`, `PrinterError`, `PrinterNotFound`, `PrinterTimeout`, `PrinterNotReady`, and `PrinterStatus`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Classic Bluetooth: [Errno 12] Out of memory
|
||||||
|
|
||||||
|
If you encounter `[Errno 12] Out of memory` failures on Classic Bluetooth connections, it typically implies a stale state in the BlueZ stack or the printer's radio. As of v0.1.17, the library automatically falls back to a BLE connection when this specific error occurs.
|
||||||
|
|
||||||
|
If you wish to resolve the underlying Classic Bluetooth issue, these steps can help:
|
||||||
|
|
||||||
|
- **Power cycle the printer**: This clears the printer's radio state and is often the only fix if the device is rejecting RFCOMM.
|
||||||
|
- **Verify Pairing**: Classic Bluetooth (RFCOMM) requires the device to be paired and trusted in the OS. You can use the "Pair Device" or "Unpair Device" buttons in the Home Assistant add-on's web UI, or run `bluetoothctl pair <MAC>` and `bluetoothctl trust <MAC>` (or `bluetoothctl remove <MAC>`) on the host. Pairing is not required for BLE.
|
||||||
|
- **Restart Bluetooth**: `systemctl restart bluetooth` on the host can clear stuck socket handles.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [ ] Emoji support in text labels. The default Pillow font has no emoji glyphs, so they render as squares. Needs two-pass rendering: split text into emoji/non-emoji segments, render emoji with Apple Color Emoji (macOS) or Noto Color Emoji (Linux) using `embedded_color=True`, then composite onto the label.
|
- [ ] Emoji support in text labels. The default Pillow font has no emoji glyphs, so they render as squares. Needs two-pass rendering: split text into emoji/non-emoji segments, render emoji with Apple Color Emoji (macOS) or Noto Color Emoji (Linux) using `embedded_color=True`, then composite onto the label.
|
||||||
|
|||||||
128
fichero/api.py
128
fichero/api.py
@@ -5,6 +5,7 @@ Start with:
|
|||||||
or:
|
or:
|
||||||
python -m fichero.api
|
python -m fichero.api
|
||||||
|
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
GET /status – Printer status
|
GET /status – Printer status
|
||||||
GET /info – Printer info (model, firmware, battery, …)
|
GET /info – Printer info (model, firmware, battery, …)
|
||||||
@@ -17,13 +18,16 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
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
|
||||||
@@ -71,8 +75,10 @@ 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.20",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -88,14 +94,42 @@ 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"
|
||||||
|
|
||||||
|
try:
|
||||||
|
template_path = Path(__file__).parent / "index.html"
|
||||||
|
template = template_path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "<h1>Error: index.html not found</h1>"
|
||||||
|
|
||||||
|
# Simple substitution for initial values
|
||||||
|
return (
|
||||||
|
template.replace("{default_address}", default_address)
|
||||||
|
.replace("{ble_selected}", " selected" if default_transport == "ble" else "")
|
||||||
|
.replace("{classic_selected}", " selected" if default_transport == "classic" else "")
|
||||||
|
.replace("{default_channel}", str(_DEFAULT_CHANNEL))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 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(
|
||||||
@@ -156,6 +190,90 @@ async def get_info(
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/pair",
|
||||||
|
summary="Pair and trust a Bluetooth device",
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
async def pair_device(
|
||||||
|
address: Annotated[str | None, Form(description="Device address (optional, overrides FICHERO_ADDR)")] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Attempt to pair and trust the device using `bluetoothctl`.
|
||||||
|
This is intended for setting up Classic Bluetooth connections.
|
||||||
|
"""
|
||||||
|
addr = _address(address)
|
||||||
|
if not addr:
|
||||||
|
raise HTTPException(status_code=422, detail="Address is required to pair.")
|
||||||
|
|
||||||
|
# Basic validation for MAC address format to mitigate injection risk.
|
||||||
|
if not re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", addr, re.IGNORECASE):
|
||||||
|
raise HTTPException(status_code=422, detail=f"Invalid address format: {addr}")
|
||||||
|
|
||||||
|
cmd = f'echo -e "pair {addr}\\ntrust {addr}\\nquit" | bluetoothctl'
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=500, detail="`bluetoothctl` command not found. Is BlueZ installed and in PATH?")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(status_code=504, detail="`bluetoothctl` command timed out after 15 seconds.")
|
||||||
|
|
||||||
|
output = stdout.decode(errors="ignore")
|
||||||
|
error = stderr.decode(errors="ignore")
|
||||||
|
|
||||||
|
if "Failed to pair" in output or "not available" in output.lower():
|
||||||
|
raise HTTPException(status_code=502, detail=f"Pairing failed. Output: {output}. Error: {error}")
|
||||||
|
|
||||||
|
return {"ok": True, "message": "Pair/trust command sent. Check output for details.", "output": output, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/unpair",
|
||||||
|
summary="Unpair a Bluetooth device",
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
async def unpair_device(
|
||||||
|
address: Annotated[str | None, Form(description="Device address (optional, overrides FICHERO_ADDR)")] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Attempt to unpair the device using `bluetoothctl`.
|
||||||
|
"""
|
||||||
|
addr = _address(address)
|
||||||
|
if not addr:
|
||||||
|
raise HTTPException(status_code=422, detail="Address is required to unpair.")
|
||||||
|
|
||||||
|
# Basic validation for MAC address format to mitigate injection risk.
|
||||||
|
if not re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", addr, re.IGNORECASE):
|
||||||
|
raise HTTPException(status_code=422, detail=f"Invalid address format: {addr}")
|
||||||
|
|
||||||
|
cmd = f'echo -e "remove {addr}\\nquit" | bluetoothctl'
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=500, detail="`bluetoothctl` command not found. Is BlueZ installed and in PATH?")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(status_code=504, detail="`bluetoothctl` command timed out after 15 seconds.")
|
||||||
|
|
||||||
|
output = stdout.decode(errors="ignore")
|
||||||
|
error = stderr.decode(errors="ignore")
|
||||||
|
|
||||||
|
if "Failed to remove" in output or "not available" in output.lower():
|
||||||
|
raise HTTPException(status_code=502, detail=f"Unpairing failed. Output: {output}. Error: {error}")
|
||||||
|
|
||||||
|
return {"ok": True, "message": "Unpair command sent. Check output for details.", "output": output, "error": error}
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/print/text",
|
"/print/text",
|
||||||
summary="Print a text label",
|
summary="Print a text label",
|
||||||
|
|||||||
307
fichero/index.html
Normal file
307
fichero/index.html
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fichero Printer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--panel: #fffaf2;
|
||||||
|
--line: #d8cdbd;
|
||||||
|
--ink: #2d241d;
|
||||||
|
--muted: #6c6258;
|
||||||
|
--accent: #b55e33;
|
||||||
|
--accent-2: #245b4b;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Noto Sans", system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, #fff8ed 0, transparent 35%),
|
||||||
|
linear-gradient(180deg, #efe4d3 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 40px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 250, 242, 0.92);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
h1, h2 { margin: 0 0 12px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 8px 24px rgba(45, 36, 29, 0.06);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
input, select, textarea, button {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
textarea { min-height: 110px; resize: vertical; }
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.inline input[type="checkbox"] { width: auto; }
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.alt { background: var(--accent-2); }
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #241f1a;
|
||||||
|
color: #f7efe4;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
.actions button { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Fichero Printer</h1>
|
||||||
|
<p class="muted">Home Assistant print console for status, text labels, and image uploads.</p>
|
||||||
|
<p class="muted">API docs remain available at <a href="docs">/docs</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Connection</h2>
|
||||||
|
<label for="address">Printer address</label>
|
||||||
|
<input id="address" value="{default_address}" placeholder="C9:48:8A:69:D5:C0">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="transport">Transport</label>
|
||||||
|
<select id="transport">
|
||||||
|
<option value="ble"{ble_selected}>BLE</option>
|
||||||
|
<option value="classic"{classic_selected}>Classic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="channel">RFCOMM channel</label>
|
||||||
|
<input id="channel" type="number" min="1" max="30" value="{default_channel}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="alt" onclick="runPost('pair')">Pair Device</button>
|
||||||
|
<button type="button" class="alt" onclick="runPost('unpair')">Unpair Device</button>
|
||||||
|
<button type="button" class="alt" onclick="runGet('status')">Get Status</button>
|
||||||
|
<button type="button" class="alt" onclick="runGet('info')">Get Info</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Output</h2>
|
||||||
|
<pre id="output">Ready.</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Print Text</h2>
|
||||||
|
<label for="text">Text</label>
|
||||||
|
<textarea id="text" placeholder="Hello from Home Assistant"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="text_density">Density</label>
|
||||||
|
<select id="text_density">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="text_copies">Copies</label>
|
||||||
|
<input id="text_copies" type="number" min="1" max="99" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="text_font_size">Font size</label>
|
||||||
|
<input id="text_font_size" type="number" min="6" max="200" value="30">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="text_label_length">Label length (mm)</label>
|
||||||
|
<input id="text_label_length" type="number" min="5" max="500" value="30">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="text_paper">Paper</label>
|
||||||
|
<select id="text_paper">
|
||||||
|
<option value="gap" selected>gap</option>
|
||||||
|
<option value="black">black</option>
|
||||||
|
<option value="continuous">continuous</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="printText()">Print Text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Print Image</h2>
|
||||||
|
<label for="image_file">Image file</label>
|
||||||
|
<input id="image_file" type="file" accept="image/*">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="image_density">Density</label>
|
||||||
|
<select id="image_density">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="image_copies">Copies</label>
|
||||||
|
<input id="image_copies" type="number" min="1" max="99" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="image_label_length">Label length (mm)</label>
|
||||||
|
<input id="image_label_length" type="number" min="5" max="500" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="inline">
|
||||||
|
<input id="image_dither" type="checkbox" checked>
|
||||||
|
<label for="image_dither">Enable dithering</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="image_paper">Paper</label>
|
||||||
|
<select id="image_paper">
|
||||||
|
<option value="gap" selected>gap</option>
|
||||||
|
<option value="black">black</option>
|
||||||
|
<option value="continuous">continuous</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick="printImage()">Print Image</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function commonParams() {
|
||||||
|
const address = document.getElementById("address").value.trim();
|
||||||
|
const classic = document.getElementById("transport").value === "classic";
|
||||||
|
const channel = document.getElementById("channel").value;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (address) params.set("address", address);
|
||||||
|
params.set("classic", String(classic));
|
||||||
|
params.set("channel", channel);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showResponse(response) {
|
||||||
|
const output = document.getElementById("output");
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
data = { detail: await response.text() };
|
||||||
|
}
|
||||||
|
output.textContent = JSON.stringify({ status: response.status, ok: response.ok, data }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGet(path) {
|
||||||
|
const response = await fetch(`${path}?${commonParams().toString()}`);
|
||||||
|
await showResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPost(path) {
|
||||||
|
const form = new FormData();
|
||||||
|
const params = commonParams();
|
||||||
|
for (const [key, value] of params.entries()) {
|
||||||
|
form.set(key, value);
|
||||||
|
}
|
||||||
|
const response = await fetch(path, { method: "POST", body: form });
|
||||||
|
await showResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printText() {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("text", document.getElementById("text").value);
|
||||||
|
form.set("density", document.getElementById("text_density").value);
|
||||||
|
form.set("copies", document.getElementById("text_copies").value);
|
||||||
|
form.set("font_size", document.getElementById("text_font_size").value);
|
||||||
|
form.set("label_length", document.getElementById("text_label_length").value);
|
||||||
|
form.set("paper", document.getElementById("text_paper").value);
|
||||||
|
form.set("address", document.getElementById("address").value.trim());
|
||||||
|
form.set("classic", String(document.getElementById("transport").value === "classic"));
|
||||||
|
form.set("channel", document.getElementById("channel").value);
|
||||||
|
const response = await fetch("print/text", { method: "POST", body: form });
|
||||||
|
await showResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printImage() {
|
||||||
|
const fileInput = document.getElementById("image_file");
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
document.getElementById("output").textContent = "Select an image file first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", fileInput.files[0]);
|
||||||
|
form.set("density", document.getElementById("image_density").value);
|
||||||
|
form.set("copies", document.getElementById("image_copies").value);
|
||||||
|
form.set("label_length", document.getElementById("image_label_length").value);
|
||||||
|
form.set("paper", document.getElementById("image_paper").value);
|
||||||
|
form.set("dither", String(document.getElementById("image_dither").checked));
|
||||||
|
form.set("address", document.getElementById("address").value.trim());
|
||||||
|
form.set("classic", String(document.getElementById("transport").value === "classic"));
|
||||||
|
form.set("channel", document.getElementById("channel").value);
|
||||||
|
const response = await fetch("print/image", { method: "POST", body: form });
|
||||||
|
await showResponse(response);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,6 +8,7 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
import errno
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ DELAY_CHUNK_GAP = 0.02 # inter-chunk pacing for BLE throughput
|
|||||||
DELAY_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
|
DELAY_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
|
||||||
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
|
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
|
||||||
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
||||||
|
BLE_CONNECT_RETRIES = 3 # retry transient BLE connect failures
|
||||||
|
BLE_CONNECT_BACKOFF = 0.7 # base backoff in seconds (linear: n * base)
|
||||||
|
|
||||||
|
|
||||||
# --- Exceptions ---
|
# --- Exceptions ---
|
||||||
@@ -88,6 +91,35 @@ 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)
|
||||||
|
if device is not None:
|
||||||
|
return device
|
||||||
|
# Fallback to active scan/match before giving up; do not fall back to
|
||||||
|
# raw address because BlueZ may then attempt BR/EDR and fail with
|
||||||
|
# br-connection-not-supported.
|
||||||
|
devices = await BleakScanner.discover(timeout=8)
|
||||||
|
for d in devices:
|
||||||
|
if d.address and d.address.lower() == address.lower():
|
||||||
|
return d
|
||||||
|
raise PrinterNotFound(
|
||||||
|
f"BLE device {address} not found during scan. "
|
||||||
|
"Ensure printer is on, awake, and in range."
|
||||||
|
)
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
|
||||||
@@ -155,13 +187,28 @@ class RFCOMMClient:
|
|||||||
sock = _socket.socket(
|
sock = _socket.socket(
|
||||||
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
||||||
)
|
)
|
||||||
sock.setblocking(False)
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
# uvloop's sock_connect path goes through getaddrinfo and doesn't
|
||||||
loop.sock_connect(sock, (self._address, self._channel)),
|
# support AF_BLUETOOTH addresses reliably. Use direct socket connect
|
||||||
timeout=10.0,
|
# in a thread instead.
|
||||||
|
sock.settimeout(10.0)
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
sock.connect,
|
||||||
|
(self._address, self._channel),
|
||||||
)
|
)
|
||||||
|
sock.setblocking(False)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
sock.close()
|
||||||
|
raise PrinterTimeout(
|
||||||
|
f"Classic Bluetooth connection timed out to {self._address} (channel {self._channel})."
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
sock.close()
|
||||||
|
raise PrinterError(
|
||||||
|
f"Classic Bluetooth connection failed for '{self._address}' (channel {self._channel}): {exc}"
|
||||||
|
) from exc
|
||||||
except Exception:
|
except Exception:
|
||||||
sock.close()
|
sock.close()
|
||||||
raise
|
raise
|
||||||
@@ -369,23 +416,99 @@ async def connect(
|
|||||||
if classic:
|
if classic:
|
||||||
if not address:
|
if not address:
|
||||||
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
||||||
async with RFCOMMClient(address, channel) as client:
|
# D11s variants are commonly exposed on channel 1 or 3.
|
||||||
pc = PrinterClient(client)
|
candidates = [channel, 1, 2, 3]
|
||||||
await pc.start()
|
channels = [ch for i, ch in enumerate(candidates) if ch > 0 and ch not in candidates[:i]]
|
||||||
yield pc
|
last_exc: Exception | None = None
|
||||||
else:
|
for ch in channels:
|
||||||
addr = address or await find_printer()
|
|
||||||
try:
|
try:
|
||||||
async with BleakClient(addr) as client:
|
async with RFCOMMClient(address, ch) as client:
|
||||||
pc = PrinterClient(client)
|
pc = PrinterClient(client)
|
||||||
await pc.start()
|
await pc.start()
|
||||||
yield pc
|
yield pc
|
||||||
except BleakDBusError as exc:
|
return
|
||||||
if "br-connection-not-supported" in str(exc).lower():
|
except (PrinterError, PrinterTimeout) as exc:
|
||||||
|
# On Linux, a stale BlueZ device state can cause RFCOMM connect()
|
||||||
|
# to fail with [Errno 12] Out of memory. This is a known quirk.
|
||||||
|
# We treat this specific error as a signal to fall back to BLE.
|
||||||
|
if isinstance(exc.__cause__, OSError) and exc.__cause__.errno == errno.ENOMEM:
|
||||||
|
print(
|
||||||
|
"Classic Bluetooth connection failed with [Errno 12] Out of memory. "
|
||||||
|
"Falling back to BLE connection."
|
||||||
|
)
|
||||||
|
classic = False # Modify flag to trigger BLE path below
|
||||||
|
last_exc = exc
|
||||||
|
break
|
||||||
|
last_exc = exc
|
||||||
|
|
||||||
|
# If the 'classic' flag is still true, it means the loop completed without
|
||||||
|
# hitting the ENOMEM fallback case, so all classic attempts failed.
|
||||||
|
if classic:
|
||||||
|
if last_exc is not None:
|
||||||
raise PrinterError(
|
raise PrinterError(
|
||||||
"BLE connection failed (br-connection-not-supported). "
|
f"Classic Bluetooth connection failed for '{address}'. "
|
||||||
|
f"Tried channels: {channels}. Last error: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
|
||||||
|
|
||||||
|
# If classic=False initially, or if it was set to False for the ENOMEM fallback:
|
||||||
|
if not classic:
|
||||||
|
target = await resolve_ble_target(address)
|
||||||
|
def _is_retryable_ble_error(exc: Exception) -> bool:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
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
|
||||||
|
forced_rescan_done = False
|
||||||
|
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
async with BleakClient(target) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
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:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "br-connection-not-supported" in msg:
|
||||||
|
last_exc = exc
|
||||||
|
if not forced_rescan_done:
|
||||||
|
forced_rescan_done = True
|
||||||
|
target = await resolve_ble_target(None)
|
||||||
|
if attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
|
raise PrinterError(
|
||||||
|
"BLE connection failed (br-connection-not-supported) after LE rescan. "
|
||||||
"Try Classic Bluetooth with classic=true and channel=1."
|
"Try Classic Bluetooth with classic=true and channel=1."
|
||||||
) from exc
|
) from exc
|
||||||
|
last_exc = exc
|
||||||
|
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
||||||
except BleakError as exc:
|
except BleakError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
raise PrinterError(f"BLE error: {exc}") from exc
|
raise PrinterError(f"BLE error: {exc}") from exc
|
||||||
|
if last_exc is not None:
|
||||||
|
raise PrinterError(
|
||||||
|
f"BLE connection failed after {BLE_CONNECT_RETRIES} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
raise PrinterError("BLE connection failed for unknown reason.")
|
||||||
|
|||||||
71
fichero_printer/CHANGELOG.md
Normal file
71
fichero_printer/CHANGELOG.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.20
|
||||||
|
|
||||||
|
- Refactored the embedded web UI to be loaded from an external `index.html` file.
|
||||||
|
|
||||||
|
## 0.1.19
|
||||||
|
|
||||||
|
- Added "Unpair Device" button to the web UI.
|
||||||
|
|
||||||
|
## 0.1.18
|
||||||
|
|
||||||
|
- Added "Pair Device" button to the web UI.
|
||||||
|
|
||||||
|
## 0.1.16
|
||||||
|
|
||||||
|
- Added automatic fallback to BLE if Classic Bluetooth fails with `[Errno 12] Out of memory`.
|
||||||
|
|
||||||
|
## 0.1.15
|
||||||
|
|
||||||
|
- Added a BLE recovery retry for `br-connection-not-supported` that forces fresh LE target resolution from scan results before failing.
|
||||||
|
|
||||||
|
## 0.1.14
|
||||||
|
|
||||||
|
- Prevented BLE fallback to raw MAC connects and now require discovered LE device resolution, reducing `br-connection-not-supported` regressions on some BlueZ hosts.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Added automatic BLE reconnect retry with backoff for transient timeout errors (`br-connection-timeout`).
|
||||||
|
|
||||||
|
## 0.1.9
|
||||||
|
|
||||||
|
- Added add-on local changelog file so Home Assistant can display release notes.
|
||||||
|
- Improved Classic Bluetooth RFCOMM connection robustness by trying fallback channels (1-3 plus configured channel).
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
- Added Home Assistant web print interface on `/` with status/info/text/image actions.
|
||||||
|
|
||||||
|
## 0.1.7
|
||||||
|
|
||||||
|
- Fixed ingress Swagger OpenAPI loading behind Home Assistant.
|
||||||
|
- Enabled `full_access` for stricter hosts blocking RFCOMM sockets.
|
||||||
|
|
||||||
|
## 0.1.6
|
||||||
|
|
||||||
|
- Added root changelog and release policy.
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
- Added `NET_RAW` capability for Classic Bluetooth sockets.
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
- Fixed RFCOMM connect path under uvloop.
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
- Added ingress/webui metadata updates.
|
||||||
@@ -22,16 +22,16 @@ direkt aus Home Assistant-Automationen, Skripten oder externen Anwendungen.
|
|||||||
| `port` | `8765` | Port des REST-API-Servers (auch im „Port-Mapping" oben anpassen) |
|
| `port` | `8765` | Port des REST-API-Servers (auch im „Port-Mapping" oben anpassen) |
|
||||||
| `ble_address` | _(leer)_ | Feste BLE-Adresse des Druckers (z.B. `AA:BB:CC:DD:EE:FF`). Leer lassen für automatischen Scan. |
|
| `ble_address` | _(leer)_ | Feste BLE-Adresse des Druckers (z.B. `AA:BB:CC:DD:EE:FF`). Leer lassen für automatischen Scan. |
|
||||||
| `transport` | `ble` | Verbindungsart: `ble` (Bluetooth Low Energy) oder `classic` (RFCOMM) |
|
| `transport` | `ble` | Verbindungsart: `ble` (Bluetooth Low Energy) oder `classic` (RFCOMM) |
|
||||||
| `channel` | `1` | RFCOMM-Kanal – nur relevant bei `transport: classic` |
|
| `channel` | `1` | RFCOMM-Kanal – nur relevant bei `transport: classic` (bei Fehlern werden zusätzlich typische Kanäle getestet) |
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -146,7 +146,10 @@ rest_command:
|
|||||||
- **BLE (Standard):** Das Add-on benötigt Zugriff auf BlueZ über D-Bus
|
- **BLE (Standard):** Das Add-on benötigt Zugriff auf BlueZ über D-Bus
|
||||||
(`host_dbus: true`). Home Assistant OS stellt BlueZ automatisch bereit.
|
(`host_dbus: true`). Home Assistant OS stellt BlueZ automatisch bereit.
|
||||||
- **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).
|
direkte Bluetooth-Adresse (kein automatischer Scan möglich) und Container-
|
||||||
|
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.2"
|
version: "0.1.15"
|
||||||
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"
|
||||||
@@ -18,7 +18,8 @@ ingress: true
|
|||||||
ingress_port: 8765
|
ingress_port: 8765
|
||||||
panel_icon: mdi:printer
|
panel_icon: mdi:printer
|
||||||
panel_title: Fichero Printer
|
panel_title: Fichero Printer
|
||||||
webui: "http://[HOST]:[PORT:8765]/docs"
|
webui: "http://[HOST]:[PORT:8765]/"
|
||||||
|
full_access: true
|
||||||
|
|
||||||
host_network: true
|
host_network: true
|
||||||
host_dbus: true
|
host_dbus: true
|
||||||
@@ -27,6 +28,7 @@ host_dbus: true
|
|||||||
# BLE uses D-Bus (host_dbus) and does not need this.
|
# BLE uses D-Bus (host_dbus) and does not need this.
|
||||||
privileged:
|
privileged:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
|
||||||
options:
|
options:
|
||||||
port: 8765
|
port: 8765
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,6 +35,7 @@ from fichero.printer import (
|
|||||||
PrinterNotFound,
|
PrinterNotFound,
|
||||||
PrinterTimeout,
|
PrinterTimeout,
|
||||||
connect,
|
connect,
|
||||||
|
find_printer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -71,8 +73,10 @@ 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,
|
||||||
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -88,14 +92,539 @@ 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" 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
|
# 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(
|
||||||
|
"/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(
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ DELAY_CHUNK_GAP = 0.02 # inter-chunk pacing for BLE throughput
|
|||||||
DELAY_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
|
DELAY_RASTER_SETTLE = 0.50 # wait for printhead after raster transfer
|
||||||
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
|
DELAY_AFTER_FEED = 0.30 # wait after form feed before stop command
|
||||||
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
DELAY_NOTIFY_EXTRA = 0.05 # extra wait for trailing BLE notification fragments
|
||||||
|
BLE_CONNECT_RETRIES = 3 # retry transient BLE connect failures
|
||||||
|
BLE_CONNECT_BACKOFF = 0.7 # base backoff in seconds (linear: n * base)
|
||||||
|
|
||||||
|
|
||||||
# --- Exceptions ---
|
# --- Exceptions ---
|
||||||
@@ -88,6 +90,35 @@ 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)
|
||||||
|
if device is not None:
|
||||||
|
return device
|
||||||
|
# Fallback to active scan/match before giving up; do not fall back to
|
||||||
|
# raw address because BlueZ may then attempt BR/EDR and fail with
|
||||||
|
# br-connection-not-supported.
|
||||||
|
devices = await BleakScanner.discover(timeout=8)
|
||||||
|
for d in devices:
|
||||||
|
if d.address and d.address.lower() == address.lower():
|
||||||
|
return d
|
||||||
|
raise PrinterNotFound(
|
||||||
|
f"BLE device {address} not found during scan. "
|
||||||
|
"Ensure printer is on, awake, and in range."
|
||||||
|
)
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
|
||||||
@@ -155,13 +186,28 @@ class RFCOMMClient:
|
|||||||
sock = _socket.socket(
|
sock = _socket.socket(
|
||||||
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
||||||
)
|
)
|
||||||
sock.setblocking(False)
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
# uvloop's sock_connect path goes through getaddrinfo and doesn't
|
||||||
loop.sock_connect(sock, (self._address, self._channel)),
|
# support AF_BLUETOOTH addresses reliably. Use direct socket connect
|
||||||
timeout=10.0,
|
# in a thread instead.
|
||||||
|
sock.settimeout(10.0)
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
sock.connect,
|
||||||
|
(self._address, self._channel),
|
||||||
)
|
)
|
||||||
|
sock.setblocking(False)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
sock.close()
|
||||||
|
raise PrinterTimeout(
|
||||||
|
f"Classic Bluetooth connection timed out to {self._address} (channel {self._channel})."
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
sock.close()
|
||||||
|
raise PrinterError(
|
||||||
|
f"Classic Bluetooth connection failed for '{self._address}' (channel {self._channel}): {exc}"
|
||||||
|
) from exc
|
||||||
except Exception:
|
except Exception:
|
||||||
sock.close()
|
sock.close()
|
||||||
raise
|
raise
|
||||||
@@ -369,23 +415,82 @@ async def connect(
|
|||||||
if classic:
|
if classic:
|
||||||
if not address:
|
if not address:
|
||||||
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
||||||
async with RFCOMMClient(address, channel) as client:
|
# D11s variants are commonly exposed on channel 1 or 3.
|
||||||
pc = PrinterClient(client)
|
candidates = [channel, 1, 2, 3]
|
||||||
await pc.start()
|
channels = [ch for i, ch in enumerate(candidates) if ch > 0 and ch not in candidates[:i]]
|
||||||
yield pc
|
last_exc: Exception | None = None
|
||||||
else:
|
for ch in channels:
|
||||||
addr = address or await find_printer()
|
|
||||||
try:
|
try:
|
||||||
async with BleakClient(addr) as client:
|
async with RFCOMMClient(address, ch) as client:
|
||||||
pc = PrinterClient(client)
|
pc = PrinterClient(client)
|
||||||
await pc.start()
|
await pc.start()
|
||||||
yield pc
|
yield pc
|
||||||
except BleakDBusError as exc:
|
return
|
||||||
if "br-connection-not-supported" in str(exc).lower():
|
except (PrinterError, PrinterTimeout) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if last_exc is not None:
|
||||||
raise PrinterError(
|
raise PrinterError(
|
||||||
"BLE connection failed (br-connection-not-supported). "
|
f"Classic Bluetooth connection failed for '{address}'. "
|
||||||
|
f"Tried channels: {channels}. Last error: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
raise PrinterError(f"Classic Bluetooth connection failed for '{address}'.")
|
||||||
|
else:
|
||||||
|
target = await resolve_ble_target(address)
|
||||||
|
def _is_retryable_ble_error(exc: Exception) -> bool:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
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
|
||||||
|
forced_rescan_done = False
|
||||||
|
for attempt in range(1, BLE_CONNECT_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
async with BleakClient(target) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
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:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "br-connection-not-supported" in msg:
|
||||||
|
last_exc = exc
|
||||||
|
if not forced_rescan_done:
|
||||||
|
forced_rescan_done = True
|
||||||
|
target = await resolve_ble_target(None)
|
||||||
|
if attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
|
raise PrinterError(
|
||||||
|
"BLE connection failed (br-connection-not-supported) after LE rescan. "
|
||||||
"Try Classic Bluetooth with classic=true and channel=1."
|
"Try Classic Bluetooth with classic=true and channel=1."
|
||||||
) from exc
|
) from exc
|
||||||
|
last_exc = exc
|
||||||
|
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
raise PrinterError(f"BLE connection failed: {exc}") from exc
|
||||||
except BleakError as exc:
|
except BleakError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if _is_retryable_ble_error(exc) and attempt < BLE_CONNECT_RETRIES:
|
||||||
|
await asyncio.sleep(BLE_CONNECT_BACKOFF * attempt)
|
||||||
|
continue
|
||||||
raise PrinterError(f"BLE error: {exc}") from exc
|
raise PrinterError(f"BLE error: {exc}") from exc
|
||||||
|
if last_exc is not None:
|
||||||
|
raise PrinterError(
|
||||||
|
f"BLE connection failed after {BLE_CONNECT_RETRIES} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
raise PrinterError("BLE connection failed for unknown reason.")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fichero-printer"
|
name = "fichero-printer"
|
||||||
version = "0.1.1"
|
version = "0.1.15"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container-fluid px-3 mt-3">
|
||||||
|
<BrowserWarning />
|
||||||
|
<LabelDesigner />
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user