Add persistent print queue

This commit is contained in:
Mikei386
2026-06-22 14:56:20 +02:00
parent 7ce97443b1
commit 34f0161350
8 changed files with 170 additions and 9 deletions
+1
View File
@@ -2,4 +2,5 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
labels/*.png labels/*.png
labels/queue/
.DS_Store .DS_Store
+4
View File
@@ -104,9 +104,13 @@ Umgebungsvariablen:
- `PRINT_COMMAND=ptouch-print --image {image}` ist das Druckkommando. - `PRINT_COMMAND=ptouch-print --image {image}` ist das Druckkommando.
- `LABEL_DPI=180` passt zum PT-P700. - `LABEL_DPI=180` passt zum PT-P700.
- `LABEL_DIR=/data/labels` speichert erzeugte PNG-Dateien. - `LABEL_DIR=/data/labels` speichert erzeugte PNG-Dateien.
- `PRINT_QUEUE_ENABLED=1` legt Druckauftraege zuerst in eine Warteschlange und druckt sie, sobald der Drucker erreichbar ist.
- `PRINT_QUEUE_RETRY_SECONDS=30` legt fest, wie oft wartende Jobs erneut versucht werden.
Wenn `ptouch-print` auf deinem Setup andere Optionen braucht, nur `PRINT_COMMAND` anpassen. Der Platzhalter `{image}` wird von der App ersetzt. Fuer Schnittoptionen kannst du optional `{cut_args}` im Kommando platzieren; ohne Platzhalter haengt die App die Schnittargumente automatisch an. Wenn `ptouch-print` auf deinem Setup andere Optionen braucht, nur `PRINT_COMMAND` anpassen. Der Platzhalter `{image}` wird von der App ersetzt. Fuer Schnittoptionen kannst du optional `{cut_args}` im Kommando platzieren; ohne Platzhalter haengt die App die Schnittargumente automatisch an.
Die Druckwarteschlange liegt unter `/data/labels/queue`. Deshalb muss `/data/labels` persistent gemountet sein, wenn Jobs einen Container-Neustart ueberleben sollen.
Hinweis: Der PT-P700 unterstuetzt laut Brother Auto Cut und Kettendruck, aber keinen Halbschnitt. Die Option `Kettendruck / Streifen` rendert mehrere Kopien als ein langes Druckbild, damit keine Vollschnitte zwischen den Labels entstehen. Hinweis: Der PT-P700 unterstuetzt laut Brother Auto Cut und Kettendruck, aber keinen Halbschnitt. Die Option `Kettendruck / Streifen` rendert mehrere Kopien als ein langes Druckbild, damit keine Vollschnitte zwischen den Labels entstehen.
Die automatische Laenge entfernt leeres Band am Ende des Etiketts. Links bzw. oben bleibt das Layout unveraendert, damit die Positionen im Editor nachvollziehbar bleiben. Die automatische Laenge entfernt leeres Band am Ende des Etiketts. Links bzw. oben bleibt das Layout unveraendert, damit die Positionen im Editor nachvollziehbar bleiben.
+124 -9
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import base64 import base64
import io import io
import json
import signal import signal
import os import os
import re import re
import shlex import shlex
import subprocess import subprocess
import threading import threading
import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -31,10 +33,14 @@ except Exception: # pragma: no cover - optional at runtime until installed
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
LABEL_DIR = Path(os.environ.get("LABEL_DIR", BASE_DIR / "labels")) LABEL_DIR = Path(os.environ.get("LABEL_DIR", BASE_DIR / "labels"))
LABEL_DIR.mkdir(parents=True, exist_ok=True) LABEL_DIR.mkdir(parents=True, exist_ok=True)
QUEUE_DIR = LABEL_DIR / "queue"
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_PRINT_COMMAND = "ptouch-print --image {image}" DEFAULT_PRINT_COMMAND = "ptouch-print --image {image}"
PRINT_COMMAND = os.environ.get("PRINT_COMMAND", DEFAULT_PRINT_COMMAND) PRINT_COMMAND = os.environ.get("PRINT_COMMAND", DEFAULT_PRINT_COMMAND)
PRINT_ENABLED = os.environ.get("PRINT_ENABLED", "1").lower() in {"1", "true", "yes", "on"} PRINT_ENABLED = os.environ.get("PRINT_ENABLED", "1").lower() in {"1", "true", "yes", "on"}
PRINT_QUEUE_ENABLED = os.environ.get("PRINT_QUEUE_ENABLED", "1").lower() in {"1", "true", "yes", "on"}
PRINT_QUEUE_RETRY_SECONDS = int(os.environ.get("PRINT_QUEUE_RETRY_SECONDS", "30"))
DPI = int(os.environ.get("LABEL_DPI", "180")) DPI = int(os.environ.get("LABEL_DPI", "180"))
PTOUCH_SUPPORTS_COPIES: bool | None = None PTOUCH_SUPPORTS_COPIES: bool | None = None
@@ -51,6 +57,7 @@ PRINTABLE_TAPE_PX = {
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "change-me") app.secret_key = os.environ.get("SECRET_KEY", "change-me")
PRINT_LOCK = threading.Lock() PRINT_LOCK = threading.Lock()
QUEUE_WAKE = threading.Event()
def mm_to_px(mm: float) -> int: def mm_to_px(mm: float) -> int:
@@ -369,6 +376,89 @@ def print_label(image_path: Path, copies: int, design: dict[str, Any]) -> list[s
return [run_print_command(image_path, copies, design)] return [run_print_command(image_path, copies, design)]
def run_print_job(image_path: Path, copies: int, design: dict[str, Any]) -> None:
if not PRINT_LOCK.acquire(blocking=False):
raise RuntimeError("Es laeuft bereits ein Druckjob. Bitte warten oder haengenden ptouch-print-Prozess beenden.")
try:
results = print_label(image_path, copies, design)
failed = next((result for result in results if result.returncode != 0), None)
if failed:
raise RuntimeError(format_print_error(failed))
finally:
PRINT_LOCK.release()
def queued_job_path(job_id: str) -> Path:
return QUEUE_DIR / f"{job_id}.json"
def enqueue_print_job(image_path: Path, copies: int, design: dict[str, Any]) -> dict[str, Any]:
now = time.time()
job = {
"id": uuid.uuid4().hex,
"image": image_path.name,
"copies": copies,
"design": design,
"createdAt": now,
"nextAttemptAt": now,
"attempts": 0,
"lastError": "",
}
target = queued_job_path(job["id"])
temp = target.with_suffix(".tmp")
temp.write_text(json.dumps(job, ensure_ascii=True, indent=2), encoding="utf-8")
temp.replace(target)
QUEUE_WAKE.set()
return job
def load_queue_jobs() -> list[dict[str, Any]]:
jobs: list[dict[str, Any]] = []
for path in sorted(QUEUE_DIR.glob("*.json")):
try:
job = json.loads(path.read_text(encoding="utf-8"))
if isinstance(job, dict):
jobs.append(job)
except Exception:
continue
return sorted(jobs, key=lambda item: float(item.get("createdAt", 0)))
def save_queue_job(job: dict[str, Any]) -> None:
queued_job_path(str(job["id"])).write_text(json.dumps(job, ensure_ascii=True, indent=2), encoding="utf-8")
def process_queue_once() -> None:
if not PRINT_ENABLED:
return
now = time.time()
for job in load_queue_jobs():
if float(job.get("nextAttemptAt", 0)) > now:
continue
image_path = LABEL_DIR / str(job.get("image", ""))
try:
if not image_path.exists():
raise RuntimeError(f"Queue-Bild fehlt: {image_path.name}")
run_print_job(image_path, int(job.get("copies", 1)), job.get("design") if isinstance(job.get("design"), dict) else {})
queued_job_path(str(job["id"])).unlink(missing_ok=True)
except Exception as exc:
job["attempts"] = int(job.get("attempts", 0)) + 1
job["lastError"] = str(exc)
job["nextAttemptAt"] = time.time() + PRINT_QUEUE_RETRY_SECONDS
save_queue_job(job)
break
def print_queue_worker() -> None:
while True:
try:
process_queue_once()
except Exception:
pass
QUEUE_WAKE.wait(PRINT_QUEUE_RETRY_SECONDS)
QUEUE_WAKE.clear()
def should_render_copies_as_strip(design: dict[str, Any], copies: int) -> bool: def should_render_copies_as_strip(design: dict[str, Any], copies: int) -> bool:
if copies <= 1: if copies <= 1:
return False return False
@@ -569,15 +659,12 @@ def api_print():
print_design = dict(design) print_design = dict(design)
print_design["cutMode"] = "cutmark" if design.get("cutMode") == "cutmark-chain" else "auto" print_design["cutMode"] = "cutmark" if design.get("cutMode") == "cutmark-chain" else "auto"
if PRINT_ENABLED: if PRINT_ENABLED:
if not PRINT_LOCK.acquire(blocking=False): if PRINT_QUEUE_ENABLED:
raise RuntimeError("Es laeuft bereits ein Druckjob. Bitte warten oder haengenden ptouch-print-Prozess beenden.") job = enqueue_print_job(image_path, print_copies, print_design)
try: queue_len = len(load_queue_jobs())
results = print_label(image_path, print_copies, print_design) message = f"Etikett wurde in die Druckwarteschlange gelegt. Position: {queue_len}."
failed = next((result for result in results if result.returncode != 0), None) return jsonify({"ok": True, "message": message, "image": f"/labels/{image_path.name}", "queued": True, "jobId": job["id"], "queueLength": queue_len})
if failed: run_print_job(image_path, print_copies, print_design)
raise RuntimeError(format_print_error(failed))
finally:
PRINT_LOCK.release()
message = "Etikett wurde an den Drucker gesendet." message = "Etikett wurde an den Drucker gesendet."
else: else:
message = f"Testmodus: Etikett erstellt, nicht gedruckt: {image_path.name}" message = f"Testmodus: Etikett erstellt, nicht gedruckt: {image_path.name}"
@@ -597,6 +684,34 @@ def api_tape_info():
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@app.get("/api/queue")
def api_queue():
jobs = load_queue_jobs()
return jsonify(
{
"ok": True,
"enabled": PRINT_QUEUE_ENABLED,
"length": len(jobs),
"jobs": [
{
"id": job.get("id"),
"image": job.get("image"),
"copies": job.get("copies"),
"attempts": job.get("attempts", 0),
"lastError": job.get("lastError", ""),
"createdAt": job.get("createdAt"),
"nextAttemptAt": job.get("nextAttemptAt"),
}
for job in jobs
],
}
)
@app.get("/labels/<path:filename>") @app.get("/labels/<path:filename>")
def labels(filename: str): def labels(filename: str):
return send_from_directory(LABEL_DIR, filename) return send_from_directory(LABEL_DIR, filename)
if PRINT_QUEUE_ENABLED:
threading.Thread(target=print_queue_worker, name="ptouch-print-queue", daemon=True).start()
+19
View File
@@ -44,6 +44,7 @@ const controls = {
tapeBgText: document.getElementById("tapeBgText"), tapeBgText: document.getElementById("tapeBgText"),
tapeTextSwatch: document.getElementById("tapeTextSwatch"), tapeTextSwatch: document.getElementById("tapeTextSwatch"),
tapeTextText: document.getElementById("tapeTextText"), tapeTextText: document.getElementById("tapeTextText"),
queueStatus: document.getElementById("queueStatus"),
zoomLabel: document.getElementById("zoomLabel"), zoomLabel: document.getElementById("zoomLabel"),
previewImage: document.getElementById("previewImage"), previewImage: document.getElementById("previewImage"),
noSelection: document.getElementById("noSelection"), noSelection: document.getElementById("noSelection"),
@@ -393,6 +394,22 @@ async function sendDesign(url) {
} }
if (payload.image) controls.previewImage.src = `${payload.image}?t=${Date.now()}`; if (payload.image) controls.previewImage.src = `${payload.image}?t=${Date.now()}`;
setMessage(payload.message || "Vorschau erstellt.", "success"); setMessage(payload.message || "Vorschau erstellt.", "success");
updateQueueStatus();
}
async function updateQueueStatus() {
if (!controls.queueStatus) return;
try {
const response = await fetch("/api/queue");
const payload = await response.json();
if (!payload.ok) return;
const firstWaiting = payload.jobs?.find((job) => job.lastError);
controls.queueStatus.textContent = payload.enabled
? `Warteschlange: ${payload.length}${firstWaiting ? " (wartet auf Drucker)" : ""}`
: "Warteschlange: aus";
} catch {
controls.queueStatus.textContent = "Warteschlange: unbekannt";
}
} }
document.querySelectorAll("[data-tool]").forEach((button) => { document.querySelectorAll("[data-tool]").forEach((button) => {
@@ -552,3 +569,5 @@ function hydrateImages() {
addObject("text", { x: 4, y: 4, w: 36, h: 10, text: "P-Touch", fontSizeMm: 5.5, bold: true }); addObject("text", { x: 4, y: 4, w: 36, h: 10, text: "P-Touch", fontSizeMm: 5.5, bold: true });
state.selectedId = null; state.selectedId = null;
draw(); draw();
updateQueueStatus();
setInterval(updateQueueStatus, 10000);
+18
View File
@@ -15,6 +15,7 @@ const fields = {
tapeTextSwatch: document.getElementById("mobileTapeTextSwatch"), tapeTextSwatch: document.getElementById("mobileTapeTextSwatch"),
tapeTextText: document.getElementById("mobileTapeTextText"), tapeTextText: document.getElementById("mobileTapeTextText"),
preview: document.getElementById("mobilePreview"), preview: document.getElementById("mobilePreview"),
queueStatus: document.getElementById("mobileQueueStatus"),
message: document.getElementById("mobileMessage"), message: document.getElementById("mobileMessage"),
}; };
@@ -131,6 +132,21 @@ async function send(url) {
} }
fields.preview.src = `${payload.image}?t=${Date.now()}`; fields.preview.src = `${payload.image}?t=${Date.now()}`;
setMessage(payload.message || "Vorschau erstellt.", "success"); setMessage(payload.message || "Vorschau erstellt.", "success");
updateQueueStatus();
}
async function updateQueueStatus() {
try {
const response = await fetch("/api/queue");
const payload = await response.json();
if (!payload.ok) return;
const firstWaiting = payload.jobs?.find((job) => job.lastError);
fields.queueStatus.textContent = payload.enabled
? `Warteschlange: ${payload.length}${firstWaiting ? " (wartet auf Drucker)" : ""}`
: "Warteschlange: aus";
} catch {
fields.queueStatus.textContent = "Warteschlange: unbekannt";
}
} }
document.getElementById("mobilePreviewBtn").addEventListener("click", () => send("/api/preview")); document.getElementById("mobilePreviewBtn").addEventListener("click", () => send("/api/preview"));
@@ -167,3 +183,5 @@ document.querySelectorAll(".stepper").forEach((stepper) => {
}); });
mobileDesign(); mobileDesign();
updateQueueStatus();
setInterval(updateQueueStatus, 10000);
+1
View File
@@ -113,6 +113,7 @@
<section> <section>
<h2>Status</h2> <h2>Status</h2>
<p>Druck: {{ 'aktiv' if print_enabled else 'Testmodus' }}</p> <p>Druck: {{ 'aktiv' if print_enabled else 'Testmodus' }}</p>
<p id="queueStatus">Warteschlange: -</p>
<code>{{ default_command }}</code> <code>{{ default_command }}</code>
</section> </section>
</aside> </aside>
+1
View File
@@ -105,6 +105,7 @@
<div class="preview"> <div class="preview">
<img id="mobilePreview" alt="Vorschau"> <img id="mobilePreview" alt="Vorschau">
</div> </div>
<p id="mobileQueueStatus" class="message">Warteschlange: -</p>
<p id="mobileMessage" class="message"></p> <p id="mobileMessage" class="message"></p>
</section> </section>
+2
View File
@@ -16,6 +16,8 @@ echo "APP_DIR=${APP_DIR}"
echo "LABEL_DIR=${LABEL_DIR}" echo "LABEL_DIR=${LABEL_DIR}"
echo "FLASK_APP=${FLASK_APP}" echo "FLASK_APP=${FLASK_APP}"
echo "PRINT_ENABLED=${PRINT_ENABLED:-1}" echo "PRINT_ENABLED=${PRINT_ENABLED:-1}"
echo "PRINT_QUEUE_ENABLED=${PRINT_QUEUE_ENABLED:-1}"
echo "PRINT_QUEUE_RETRY_SECONDS=${PRINT_QUEUE_RETRY_SECONDS:-30}"
echo "PRINT_COMMAND=${PRINT_COMMAND:-ptouch-print --image {image}}" echo "PRINT_COMMAND=${PRINT_COMMAND:-ptouch-print --image {image}}"
python3 - <<'PY' python3 - <<'PY'