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
+124 -9
View File
@@ -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/<path:filename>")
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()
+19
View File
@@ -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);
+18
View File
@@ -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);
+1
View File
@@ -113,6 +113,7 @@
<section>
<h2>Status</h2>
<p>Druck: {{ 'aktiv' if print_enabled else 'Testmodus' }}</p>
<p id="queueStatus">Warteschlange: -</p>
<code>{{ default_command }}</code>
</section>
</aside>
+1
View File
@@ -105,6 +105,7 @@
<div class="preview">
<img id="mobilePreview" alt="Vorschau">
</div>
<p id="mobileQueueStatus" class="message">Warteschlange: -</p>
<p id="mobileMessage" class="message"></p>
</section>