From 34f0161350ca8722f1dc2a407c10e4300afa3037 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:56:20 +0200 Subject: [PATCH] Add persistent print queue --- .gitignore | 1 + README.md | 4 ++ app/app.py | 133 +++++++++++++++++++++++++++++++++++--- app/static/editor.js | 19 ++++++ app/static/mobile.js | 18 ++++++ app/templates/index.html | 1 + app/templates/mobile.html | 1 + start.sh | 2 + 8 files changed, 170 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 5d3d1f7..9cdabb0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ *.pyc .venv/ labels/*.png +labels/queue/ .DS_Store diff --git a/README.md b/README.md index dc58448..0f670aa 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,13 @@ Umgebungsvariablen: - `PRINT_COMMAND=ptouch-print --image {image}` ist das Druckkommando. - `LABEL_DPI=180` passt zum PT-P700. - `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. +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. Die automatische Laenge entfernt leeres Band am Ende des Etiketts. Links bzw. oben bleibt das Layout unveraendert, damit die Positionen im Editor nachvollziehbar bleiben. diff --git a/app/app.py b/app/app.py index 1cad06f..7b95179 100644 --- a/app/app.py +++ b/app/app.py @@ -2,12 +2,14 @@ from __future__ import annotations import base64 import io +import json import signal import os import re import shlex import subprocess import threading +import time import uuid from pathlib import Path 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 LABEL_DIR = Path(os.environ.get("LABEL_DIR", BASE_DIR / "labels")) 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}" 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_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")) PTOUCH_SUPPORTS_COPIES: bool | None = None @@ -51,6 +57,7 @@ PRINTABLE_TAPE_PX = { app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", "change-me") PRINT_LOCK = threading.Lock() +QUEUE_WAKE = threading.Event() 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)] +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: if copies <= 1: return False @@ -569,15 +659,12 @@ def api_print(): print_design = dict(design) print_design["cutMode"] = "cutmark" if design.get("cutMode") == "cutmark-chain" else "auto" if PRINT_ENABLED: - 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, print_copies, print_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() + if PRINT_QUEUE_ENABLED: + job = enqueue_print_job(image_path, print_copies, print_design) + queue_len = len(load_queue_jobs()) + message = f"Etikett wurde in die Druckwarteschlange gelegt. Position: {queue_len}." + return jsonify({"ok": True, "message": message, "image": f"/labels/{image_path.name}", "queued": True, "jobId": job["id"], "queueLength": queue_len}) + run_print_job(image_path, print_copies, print_design) message = "Etikett wurde an den Drucker gesendet." else: 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 +@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/") def labels(filename: str): return send_from_directory(LABEL_DIR, filename) + + +if PRINT_QUEUE_ENABLED: + threading.Thread(target=print_queue_worker, name="ptouch-print-queue", daemon=True).start() diff --git a/app/static/editor.js b/app/static/editor.js index cb9915a..5f61b39 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -44,6 +44,7 @@ const controls = { tapeBgText: document.getElementById("tapeBgText"), tapeTextSwatch: document.getElementById("tapeTextSwatch"), tapeTextText: document.getElementById("tapeTextText"), + queueStatus: document.getElementById("queueStatus"), zoomLabel: document.getElementById("zoomLabel"), previewImage: document.getElementById("previewImage"), noSelection: document.getElementById("noSelection"), @@ -393,6 +394,22 @@ async function sendDesign(url) { } if (payload.image) controls.previewImage.src = `${payload.image}?t=${Date.now()}`; 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) => { @@ -552,3 +569,5 @@ function hydrateImages() { addObject("text", { x: 4, y: 4, w: 36, h: 10, text: "P-Touch", fontSizeMm: 5.5, bold: true }); state.selectedId = null; draw(); +updateQueueStatus(); +setInterval(updateQueueStatus, 10000); diff --git a/app/static/mobile.js b/app/static/mobile.js index a19dcd0..6784aa9 100644 --- a/app/static/mobile.js +++ b/app/static/mobile.js @@ -15,6 +15,7 @@ const fields = { tapeTextSwatch: document.getElementById("mobileTapeTextSwatch"), tapeTextText: document.getElementById("mobileTapeTextText"), preview: document.getElementById("mobilePreview"), + queueStatus: document.getElementById("mobileQueueStatus"), message: document.getElementById("mobileMessage"), }; @@ -131,6 +132,21 @@ async function send(url) { } fields.preview.src = `${payload.image}?t=${Date.now()}`; 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")); @@ -167,3 +183,5 @@ document.querySelectorAll(".stepper").forEach((stepper) => { }); mobileDesign(); +updateQueueStatus(); +setInterval(updateQueueStatus, 10000); diff --git a/app/templates/index.html b/app/templates/index.html index 33ad063..4b07fac 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -113,6 +113,7 @@

Status

Druck: {{ 'aktiv' if print_enabled else 'Testmodus' }}

+

Warteschlange: -

{{ default_command }}
diff --git a/app/templates/mobile.html b/app/templates/mobile.html index 350a96d..663c82b 100644 --- a/app/templates/mobile.html +++ b/app/templates/mobile.html @@ -105,6 +105,7 @@
Vorschau
+

Warteschlange: -

diff --git a/start.sh b/start.sh index 4c8d12c..27bbdc0 100644 --- a/start.sh +++ b/start.sh @@ -16,6 +16,8 @@ echo "APP_DIR=${APP_DIR}" echo "LABEL_DIR=${LABEL_DIR}" echo "FLASK_APP=${FLASK_APP}" 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}}" python3 - <<'PY'