"""LabelPi – Etikettendruck für Brother-QL-Drucker per Weboberfläche (MIT-Lizenz).""" import base64, glob, io, json, os, re, socket, sqlite3, subprocess, tempfile, threading, time from flask import Flask, Response, jsonify, request, send_file, send_from_directory from PIL import Image import barcode import markdown from ppf.datamatrix import DataMatrix import qrcode from werkzeug.security import check_password_hash, generate_password_hash from brother_ql.conversion import convert from brother_ql.raster import BrotherQLRaster from brother_ql.backends.helpers import send, discover from brother_ql.labels import ALL_LABELS from brother_ql.models import ALL_MODELS VERSION = "0.6.0" BASE = os.path.dirname(os.path.abspath(__file__)) DB = os.path.join(BASE, "labels.db") app = Flask(__name__, static_folder="static") lock = threading.Lock() MODELS = {m.identifier: m for m in ALL_MODELS} PIDS = {0x2015: "QL-500", 0x2016: "QL-550", 0x2027: "QL-560", 0x2028: "QL-570", 0x2029: "QL-580N", 0x201b: "QL-650TD", 0x2042: "QL-700", 0x2043: "QL-710W", 0x2044: "QL-720NW", 0x209b: "QL-800", 0x209c: "QL-810W", 0x209d: "QL-820NWB", 0x2020: "QL-1050", 0x202a: "QL-1060N"} # USB-Produkt-ID -> Modell # DK-Nummern (Größen kommen aus der brother_ql-Bibliothek); leer = unbekannt DK = {"12": "DK-22214", "29": "DK-22210", "38": "DK-22225", "50": "DK-22223", "54": "DK-N55224", "62": "DK-22205", "102": "DK-22243", "17x54": "DK-11204", "17x87": "DK-11203", "23x23": "DK-11221", "29x90": "DK-11201", "39x90": "DK-11208", "39x48": "DK-11220", "62x29": "DK-11209", "62x100": "DK-11202", "102x51": "DK-11240", "102x152": "DK-11241", "d12": "DK-11219", "d24": "DK-11218", "d58": "DK-11207"} LABELS = [] for l in ALL_LABELS: if l.identifier == "62red": continue # Rot/Schwarz: Designer ist nur Schwarz/Weiß ff, w = l.form_factor.name, l.tape_size name = f"{w[0]} mm Endlos" if ff == "ENDLESS" else f"Ø {w[0]} mm rund" if ff == "ROUND_DIE_CUT" else f"{w[0]}×{w[1]} mm" LABELS.append(dict(id=l.identifier, name=name, dk=DK.get(l.identifier, ""), endless=ff == "ENDLESS", tw=w[0], tl=w[1], across=l.dots_printable[0], along=l.dots_printable[1], only=list(l.restricted_to_models))) LBL = {l["id"]: l for l in LABELS} def db(): c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c with db() as c: c.executescript(""" CREATE TABLE IF NOT EXISTS templates(id INTEGER PRIMARY KEY, name TEXT, label TEXT, design TEXT, png BLOB, ts TEXT DEFAULT (datetime('now','localtime'))); CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY, ts TEXT DEFAULT (datetime('now','localtime')), label TEXT, copies INT, rotate INT, dither INT, design TEXT, png BLOB); CREATE TABLE IF NOT EXISTS settings(k TEXT PRIMARY KEY, v TEXT); CREATE TABLE IF NOT EXISTS counters(name TEXT PRIMARY KEY, v INTEGER);""") def migrate(): with db() as c: try: c.execute("ALTER TABLE templates ADD COLUMN folder TEXT DEFAULT ''") except sqlite3.OperationalError: pass migrate() def sget(k, d=None): with db() as c: r = c.execute("SELECT v FROM settings WHERE k=?", (k,)).fetchone() v = json.loads(r["v"]) if r else None return d if v is None else v def sput(k, v): with db() as c: c.execute("INSERT OR REPLACE INTO settings(k,v) VALUES(?,?)", (k, json.dumps(v))) def b64(s): return base64.b64decode(s.split(",", 1)[1]) def pngd(b): return "data:image/png;base64," + base64.b64encode(b).decode() def gray(png): b = io.BytesIO(); Image.open(io.BytesIO(png)).convert("L").save(b, "PNG", optimize=True); return b.getvalue() # ---------- Login (optional) ---------- OK = set() @app.before_request def guard(): a = sget("auth") if not a: return h = request.headers.get("Authorization", "") if h in OK: return r = request.authorization if r and r.username == a["user"] and check_password_hash(a["hash"], r.password or ""): OK.add(h); return return Response("Login erforderlich", 401, {"WWW-Authenticate": 'Basic realm="LabelPi"'}) @app.get("/api/auth") def a_get(): a = sget("auth"); return jsonify(enabled=bool(a), user=(a or {}).get("user", "")) @app.post("/api/auth") def a_set(): j = request.get_json() or {}; old = sget("auth") if not j.get("enabled"): sput("auth", None) else: user, pw = (j.get("user") or "").strip(), j.get("password") or "" if not user or (not pw and not old): return jsonify(error="Benutzername und Passwort angeben"), 400 sput("auth", {"user": user, "hash": generate_password_hash(pw, method="pbkdf2:sha256:60000") if pw else old["hash"]}) OK.clear(); return jsonify(ok=True) # ---------- Drucker ---------- def reachable(h): try: socket.create_connection((h, 9100), 0.7).close(); return True except OSError: return False def printers(): out = [] try: for d in discover("pyusb"): dev = d["instance"]; pid = dev.idProduct if pid in PIDS: # gleiche Modelle werden über den USB-Port unterschieden port = f"{dev.bus}-" + ".".join(map(str, dev.port_numbers or ())) out.append(dict(id=f"usb:{pid:04x}:{port}", name=PIDS[pid] + " (USB)", model=PIDS[pid], host="", port=port, uri=f"usb://0x04f9:0x{pid:04x}", backend="pyusb", online=True, dev=dev)) except Exception: pass for p in out: if sum(q["model"] == p["model"] for q in out) > 1: p["name"] = f'{p["model"]} (USB-Port {p["port"]})' for p in sget("printers", []): out.append(dict(id="net:" + p["host"], name=f'{p["name"]} (Netzwerk)', model=p["model"], host=p["host"], uri=f'tcp://{p["host"]}:9100', backend="network", online=reachable(p["host"]))) for p in out: p["cutting"] = MODELS[p["model"]].cutting return out def pick(pid): ps = printers() p = next((x for x in ps if x["id"] == pid), None) or next((x for x in ps if x["online"]), None) if not p: raise RuntimeError("Kein Drucker gefunden (USB, Strom, Netzwerk prüfen)") return p def compose(tiles, label, rot, grid): """Mehrere Etiketten (Kacheln) als Raster auf eine Endlosrolle legen.""" W = LBL[label]["across"]; cols, rows = int(grid["cols"]), int(grid["rows"]); gap = round(float(grid.get("gap", 2)) * 300 / 25.4) out = [] for i in range(0, len(tiles), cols * rows): grp = [t.rotate(-90, expand=True) if rot else t for t in tiles[i:i + cols * rows]] tw, th = grp[0].size; used = -(-len(grp) // cols) if cols * tw + (cols - 1) * gap > W: raise ValueError("Das Raster ist breiter als die Rolle (Etikettenbreite/Spalten prüfen)") x0 = (W - (cols * tw + (cols - 1) * gap)) // 2 im = Image.new("RGB", (W, used * th + (used - 1) * gap), "white") for k, t in enumerate(grp): im.paste(t, (x0 + (k % cols) * (tw + gap), (k // cols) * (th + gap))) out.append(im) return out def do_print(pngs, label, rot, dither, pid, cut, grid=None): tiles = [Image.open(io.BytesIO(b)).convert("RGB") for b in pngs] if grid and LBL[label]["endless"] and int(grid["cols"]) * int(grid["rows"]) > 1: imgs = compose(tiles, label, rot, grid) else: imgs = [t.rotate(-90, expand=True) if rot else t for t in tiles] # 90° im Uhrzeigersinn with lock: for i in range(0, len(imgs), 20): # in Blöcken, schont den Speicher des Pi p = pick(pid) ins = convert(qlr=BrotherQLRaster(p["model"]), images=imgs[i:i + 20], label=label, rotate="0", threshold=70.0, dither=dither, compress=False, red=False, dpi_600=False, hq=True, cut=bool(cut) and p["cutting"]) send(instructions=ins, printer_identifier=p.get("dev") or p["uri"], backend_identifier=p["backend"], blocking=True) @app.errorhandler(Exception) def err(e): return jsonify(error=str(e)), getattr(e, "code", 500) @app.get("/") def index(): return send_from_directory("static", "index.html") @app.get("/api/models") def models(): return jsonify(list(MODELS)) @app.get("/api/printers") def p_list(): return jsonify([{k: v for k, v in p.items() if k != "dev"} for p in printers()]) @app.post("/api/printers") def p_add(): j = request.get_json() or {}; host = (j.get("host") or "").strip() if not host or j.get("model") not in MODELS: return jsonify(error="Name, Adresse und Modell angeben"), 400 sput("printers", [p for p in sget("printers", []) if p["host"] != host] + [dict(name=(j.get("name") or host).strip(), host=host, model=j["model"])]) return jsonify(ok=True) @app.post("/api/printers/delete") def p_del(): h = (request.get_json() or {}).get("host"); sput("printers", [p for p in sget("printers", []) if p["host"] != h]); return jsonify(ok=True) @app.get("/api/labels") def labels(): p = next((x for x in printers() if x["id"] == request.args.get("printer")), None) model = p["model"] if p else "QL-500" return jsonify([l for l in LABELS if not l["only"] or model in l["only"]]) @app.get("/api/bc") def bc(): t, d = request.args.get("t", "code128"), request.args.get("d", "") if not d or t not in ("code128", "ean13", "ean8", "code39", "gs1_128", "dm"): return jsonify(error="ungültig"), 400 try: if t == "dm": return jsonify(m=["".join(map(str, r)) for r in DataMatrix(d).matrix]) cls = barcode.get_barcode_class(t) return jsonify(m=(cls(d, add_checksum=False) if t == "code39" else cls(d)).build()[0]) except Exception as e: return jsonify(error=str(e)), 400 @app.post("/api/pdfin") def pdfin(): f, page = request.files["f"], max(1, int(request.form.get("page", 1))) try: with tempfile.TemporaryDirectory() as t: src = os.path.join(t, "in.pdf"); f.save(src) mt = re.search(r"Pages:\s+(\d+)", subprocess.run(["pdfinfo", src], capture_output=True, text=True).stdout) pages = int(mt.group(1)) if mt else 1; page = min(page, pages) def enc(fp, mx): im = Image.open(fp).convert("L"); mm = (im.width / 300 * 25.4, im.height / 300 * 25.4) im.thumbnail((mx, mx)); b = io.BytesIO(); im.save(b, "PNG", optimize=True); return pngd(b.getvalue()), mm if request.form.get("all") == "1": # alle Seiten (max. 60) -> Serie subprocess.run(["pdftoppm", "-r", "300", "-png", "-l", "60", src, os.path.join(t, "o")], check=True, timeout=600) res = [enc(fp, 2400) for fp in sorted(glob.glob(os.path.join(t, "o-*.png")))] return jsonify(pngs=[r[0] for r in res], w=res[0][1][0], h=res[0][1][1], pages=len(res)) subprocess.run(["pdftoppm", "-r", "300", "-png", "-f", str(page), "-l", str(page), "-singlefile", src, os.path.join(t, "o")], check=True, timeout=120) png, (w, h) = enc(os.path.join(t, "o.png"), 4000) except FileNotFoundError: return jsonify(error="poppler-utils fehlt: sudo apt install poppler-utils"), 500 return jsonify(png=png, w=w, h=h, pages=pages) @app.get("/qr") def qr(): q = qrcode.QRCode(border=0, box_size=8, error_correction=qrcode.constants.ERROR_CORRECT_M) q.add_data(request.args.get("d", "")); q.make(fit=True) b = io.BytesIO(); q.make_image(fill_color="black", back_color="white").save(b); b.seek(0) return send_file(b, mimetype="image/png") @app.post("/api/pdf") def pdf(): b = io.BytesIO(); Image.open(io.BytesIO(b64(request.get_json()["png"]))).convert("RGB").save(b, "PDF", resolution=300.0); b.seek(0) return send_file(b, mimetype="application/pdf") def log(j, n): with db() as c: c.execute("INSERT INTO history(label,copies,rotate,dither,design,png) VALUES(?,?,?,?,?,?)", (j["label"], n, int(bool(j.get("rotate"))), int(bool(j.get("dither"))), json.dumps(j["design"]), gray(b64(j["png"])))) c.execute("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY id DESC LIMIT 1000)") @app.post("/api/print") def print_(): j = request.get_json(); n = max(1, min(50, int(j.get("copies", 1)))) pngs = [b64(x) for x in j["pngs"]] do_print([b for b in pngs for _ in range(n)][:600], j["label"], bool(j.get("rotate")), bool(j.get("dither")), j.get("printer"), j.get("cut"), j.get("grid")) if j.get("log", True): j["png"] = j["pngs"][0]; log(j, n * len(pngs)) return jsonify(ok=True) @app.post("/api/save") def save(): log(request.get_json(), 0); return jsonify(ok=True) # ---------- Verlauf ---------- @app.get("/api/history") def h_list(): with db() as c: return jsonify([dict(r) for r in c.execute("SELECT id,ts,label,copies FROM history ORDER BY id DESC LIMIT 200")]) @app.get("/api/history/") def h_get(i): with db() as c: return jsonify(design=c.execute("SELECT design FROM history WHERE id=?", (i,)).fetchone()["design"]) @app.get("/api/history/.png") def h_png(i): with db() as c: return send_file(io.BytesIO(c.execute("SELECT png FROM history WHERE id=?", (i,)).fetchone()["png"]), mimetype="image/png") @app.post("/api/history//reprint") def h_reprint(i): j = request.get_json(silent=True) or {} with db() as c: r = c.execute("SELECT * FROM history WHERE id=?", (i,)).fetchone() n = max(1, min(50, int(j.get("copies", 1)))) do_print([r["png"]] * n, r["label"], bool(r["rotate"]), bool(r["dither"]), j.get("printer"), j.get("cut")) with db() as c: c.execute("UPDATE history SET ts=datetime('now','localtime'), copies=? WHERE id=?", (n, i)) return jsonify(ok=True) @app.post("/api/history/delete") def h_del_many(): ids = [int(x) for x in (request.get_json() or {}).get("ids", [])] with db() as c: c.executemany("DELETE FROM history WHERE id=?", [(i,) for i in ids]) return jsonify(ok=True, n=len(ids)) @app.delete("/api/history/") def h_del(i): with db() as c: c.execute("DELETE FROM history WHERE id=?", (i,)) return jsonify(ok=True) # ---------- Vorlagen ---------- @app.get("/api/templates") def t_list(): with db() as c: return jsonify([dict(r) for r in c.execute("SELECT id,name,label,COALESCE(folder,'') AS folder FROM templates ORDER BY name COLLATE NOCASE")]) @app.post("/api/templates") def t_add(): j = request.get_json() with db() as c: c.execute("INSERT INTO templates(name,label,design,png,folder) VALUES(?,?,?,?,?)", (j["name"], j["label"], json.dumps(j["design"]), gray(b64(j["png"])), (j.get("folder") or "").strip())) return jsonify(ok=True) @app.put("/api/templates/") def t_put(i): j = request.get_json() with db() as c: c.execute("UPDATE templates SET name=?, folder=? WHERE id=?", (j["name"].strip(), (j.get("folder") or "").strip(), i)) return jsonify(ok=True) @app.get("/api/templates/") def t_get(i): with db() as c: return jsonify(design=c.execute("SELECT design FROM templates WHERE id=?", (i,)).fetchone()["design"]) @app.get("/api/templates/.png") def t_png(i): with db() as c: return send_file(io.BytesIO(c.execute("SELECT png FROM templates WHERE id=?", (i,)).fetchone()["png"]), mimetype="image/png") @app.delete("/api/templates/") def t_del(i): with db() as c: c.execute("DELETE FROM templates WHERE id=?", (i,)) return jsonify(ok=True) # ---------- Export / Import (JSON) ---------- @app.get("/api/export") def export(): tpl = request.args.get("kind") == "templates" ids = [int(x) for x in request.args.get("ids", "").split(",") if x.strip().isdigit()] q = f"SELECT * FROM {'templates' if tpl else 'history'}" + (f" WHERE id IN ({','.join('?' * len(ids))})" if ids else "") with db() as c: rows = c.execute(q, ids).fetchall() items = [dict(kind="template" if tpl else "label", name=r["name"] if tpl else "", folder=(r["folder"] or "") if tpl else "", label=r["label"], design=json.loads(r["design"]), png=pngd(r["png"])) for r in rows] return Response(json.dumps(dict(labelpi=1, items=items)), mimetype="application/json", headers={"Content-Disposition": f"attachment; filename=labelpi-{'vorlagen' if tpl else 'etiketten'}.json"}) @app.post("/api/import") def imp(): n = 0 with db() as c: for i in (request.get_json() or {}).get("items", []): png, d = gray(b64(i["png"])), json.dumps(i["design"]) if i.get("kind") == "template": c.execute("INSERT INTO templates(name,label,design,png,folder) VALUES(?,?,?,?,?)", (i.get("name") or "Import", i["label"], d, png, i.get("folder", ""))) else: c.execute("INSERT INTO history(label,copies,rotate,dither,design,png) VALUES(?,?,?,?,?,?)", (i["label"], 0, int(bool(i["design"].get("rot"))), 0, d, png)) n += 1 return jsonify(ok=True, n=n) # ---------- Einstellungen / System ---------- @app.get("/api/settings") def s_get(): return jsonify(sget("ui", {})) @app.post("/api/settings") def s_set(): j = request.get_json() or {}; d = sget("ui", {}) d.update({k: j[k] for k in ("label", "len", "rot", "dither", "printer", "cut", "roll", "cw", "grid", "cmk") if k in j}); sput("ui", d); return jsonify(ok=True) # ---------- Zähler (merken die nächste Nummer dauerhaft) ---------- @app.get("/api/counters") def c_list(): with db() as c: return jsonify({r["name"]: r["v"] for r in c.execute("SELECT name,v FROM counters")}) @app.post("/api/counters/reserve") def c_reserve(): out = {} with db() as c: for k, n in ((request.get_json() or {}).get("names") or {}).items(): k = k.lower()[:40]; r = c.execute("SELECT v FROM counters WHERE name=?", (k,)).fetchone(); v = r["v"] if r else 1 c.execute("INSERT OR REPLACE INTO counters(name,v) VALUES(?,?)", (k, v + max(1, int(n)))); out[k] = v return jsonify(out) @app.post("/api/counters/set") def c_set(): j = request.get_json() or {} with db() as c: c.execute("INSERT OR REPLACE INTO counters(name,v) VALUES(?,?)", ((j.get("name") or "default").lower()[:40], int(j.get("value", 1)))) return jsonify(ok=True) @app.post("/api/counters/delete") def c_del(): with db() as c: c.execute("DELETE FROM counters WHERE name=?", ((request.get_json() or {}).get("name", "").lower(),)) return jsonify(ok=True) # ---------- Sprachen: eingebaut (static/lang) und eigene (lang/) ---------- LANGD = [os.path.join(BASE, "static", "lang"), os.path.join(BASE, "lang")] CODE = re.compile(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?") def lang_file(code): if CODE.fullmatch(code or ""): for d in reversed(LANGD): # eigene Dateien haben Vorrang p = os.path.join(d, code + ".json") if os.path.isfile(p): return p @app.get("/api/langs") def langs(): out = {"de": "Deutsch"} for d in LANGD: for p in sorted(glob.glob(os.path.join(d, "*.json"))): try: out[os.path.basename(p)[:-5]] = json.load(open(p, encoding="utf-8")).get("_name", os.path.basename(p)[:-5]) except Exception: pass return jsonify([dict(code=k, name=v, custom=os.path.isfile(os.path.join(LANGD[1], k + ".json"))) for k, v in out.items()]) @app.get("/api/lang/") def lang(code): p = lang_file(code) return Response(open(p, encoding="utf-8").read(), mimetype="application/json") if p else jsonify(strings={}) @app.post("/api/lang") def lang_up(): f = request.files["f"]; code = os.path.basename(f.filename or "")[:-5] try: data = json.loads(f.read().decode("utf-8-sig")) except Exception: data = None if not CODE.fullmatch(code) or not isinstance(data, dict) or not data.get("_name") or not isinstance(data.get("strings"), dict): return jsonify(error="Ungültige Sprachdatei (Dateiname = Sprachcode, Inhalt mit _name und strings)"), 400 os.makedirs(LANGD[1], exist_ok=True) json.dump(data, open(os.path.join(LANGD[1], code + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1) return jsonify(ok=True) @app.post("/api/lang/delete") def lang_del(): code = (request.get_json() or {}).get("code", "") if CODE.fullmatch(code) and os.path.isfile(os.path.join(LANGD[1], code + ".json")): os.unlink(os.path.join(LANGD[1], code + ".json")) return jsonify(ok=True) @app.get("/api/langtemplate") def lang_tpl(): keys = json.load(open(os.path.join(LANGD[0], "en.json"), encoding="utf-8"))["strings"] return Response(json.dumps({"_name": "Name der Sprache", "strings": {k: "" for k in keys}}, ensure_ascii=False, indent=1), mimetype="application/json", headers={"Content-Disposition": "attachment; filename=neue-sprache.json"}) @app.get("/api/help") def help_(): lg = request.args.get("lang", "de") if not CODE.fullmatch(lg): lg = "de" for p in (os.path.join(LANGD[1], f"ANLEITUNG.{lg}.md"), os.path.join(BASE, "docs", f"ANLEITUNG.{lg}.md"), os.path.join(BASE, "docs", "ANLEITUNG.md")): if os.path.isfile(p): return Response(markdown.markdown(open(p, encoding="utf-8").read(), extensions=["tables", "fenced_code"]), mimetype="text/html") return Response("", mimetype="text/html") @app.get("/api/backup") def backup(): t = tempfile.NamedTemporaryFile(suffix=".db", delete=False); t.close() src, dst = sqlite3.connect(DB), sqlite3.connect(t.name); src.backup(dst); dst.close(); src.close() r = send_file(t.name, as_attachment=True, download_name=time.strftime("labelpi-backup-%Y%m%d-%H%M.db")) r.call_on_close(lambda: os.unlink(t.name)); return r @app.post("/api/restore") def restore(): t = tempfile.NamedTemporaryFile(suffix=".db", delete=False); request.files["f"].save(t.name); t.close() try: s = sqlite3.connect(t.name); names = {r[0] for r in s.execute("SELECT name FROM sqlite_master WHERE type='table'")} if not {"templates", "history", "settings"} <= names: raise ValueError("Keine gültige LabelPi-Sicherung") d = sqlite3.connect(DB); s.backup(d); d.close(); s.close() finally: os.unlink(t.name) migrate(); OK.clear(); return jsonify(ok=True) @app.post("/api/power") def power(): a = (request.get_json() or {}).get("action") if a not in ("reboot", "poweroff"): return jsonify(error="Ungültige Aktion"), 400 cmd = ["sudo", "-n", "/usr/bin/systemctl", a] if subprocess.run(cmd[:2] + ["-l"] + cmd[2:], capture_output=True).returncode != 0: return jsonify(error="Keine sudo-Berechtigung (install.sh erneut ausführen)"), 500 subprocess.Popen("sleep 1; " + " ".join(cmd), shell=True) return jsonify(ok=True) if __name__ == "__main__": app.run("0.0.0.0", int(os.environ.get("PORT", 80)), threaded=True)