Add persistent print queue
This commit is contained in:
@@ -2,4 +2,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
labels/*.png
|
labels/*.png
|
||||||
|
labels/queue/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user