release/0.1.20 #4

Merged
Paul merged 2 commits from release/0.1.20 into main 2026-03-16 09:21:35 +00:00
6 changed files with 490 additions and 301 deletions

View File

@@ -4,6 +4,36 @@ 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

View File

@@ -140,6 +140,18 @@ asyncio.run(main())
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
- [ ] 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.

View File

@@ -5,6 +5,7 @@ Start with:
or:
python -m fichero.api
Endpoints:
GET /status Printer status
GET /info Printer info (model, firmware, battery, …)
@@ -17,8 +18,10 @@ from __future__ import annotations
import argparse
import asyncio
import io
import re
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
@@ -72,7 +75,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
app = FastAPI(
title="Fichero Printer API",
description="REST API for the Fichero D11s (AiYin) thermal label printer.",
version="0.1.13",
version="0.1.20",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
@@ -94,301 +97,20 @@ def _address(address: str | None) -> str | None:
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">
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>"
<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>"""
# 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))
)
# ---------------------------------------------------------------------------
@@ -468,6 +190,90 @@ async def get_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(
"/print/text",
summary="Print a text label",

307
fichero/index.html Normal file
View 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>

View File

@@ -8,6 +8,7 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
import asyncio
import sys
import errno
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
@@ -427,14 +428,31 @@ async def connect(
yield pc
return
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(
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:
# 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()

View File

@@ -1,5 +1,21 @@
# 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.