Initiale Ablage der umsortierten Skrift-Programme

Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 12:21:08 +02:00
commit 0341c4eaea
90 changed files with 40401 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
"""
AutoRecover - Drucker Fehler Handler
Erkennt das "Error"-Fenster (Java AWT) und klickt automatisch "Recover" (max. 3x).
"""
import win32gui
import win32con
import ctypes
import time
MAX_VERSUCHE = 3
PRUEF_INTERVAL = 2 # Sekunden zwischen den Checks
def click_recover_button(hwnd):
"""
Holt die Fensterposition und klickt auf den Recover-Button.
Der Button ist zentriert am unteren Rand des Fensters.
"""
# Fenster in den Vordergrund holen
win32gui.SetForegroundWindow(hwnd)
time.sleep(0.3)
# Fensterposition und -größe ermitteln
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width = right - left
height = bottom - top
# Recover-Button liegt ca. bei: horizontal zentriert, ~85% der Höhe
click_x = left + width // 2
click_y = top + int(height * 0.85)
# Mausklick simulieren
ctypes.windll.user32.SetCursorPos(click_x, click_y)
time.sleep(0.1)
ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # linke Taste runter
ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # linke Taste hoch
print(f" → Klick auf Position ({click_x}, {click_y})")
def main():
versuche = 0
print("AutoRecover läuft... (Strg+C zum Beenden)")
print(f"Maximal {MAX_VERSUCHE} Versuche.\n")
while versuche < MAX_VERSUCHE:
hwnd = win32gui.FindWindow(None, "Error")
if hwnd and win32gui.IsWindowVisible(hwnd):
versuche += 1
print(f"[Versuch {versuche}/{MAX_VERSUCHE}] Fehlerfenster gefunden!")
time.sleep(0.5)
click_recover_button(hwnd)
if versuche >= MAX_VERSUCHE:
print(f"\nMaximale Versuche ({MAX_VERSUCHE}) erreicht. Programm beendet.")
break
time.sleep(3)
else:
time.sleep(PRUEF_INTERVAL)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nProgramm manuell beendet.")

View File

@@ -0,0 +1,11 @@
@echo off
echo PyInstaller wird installiert falls noetig...
pip install pyinstaller pywin32 --quiet
echo.
echo Erstelle AutoRecover.exe ...
python -m PyInstaller --onefile --noconsole --name AutoRecover auto_recover.py
echo.
echo Fertig! Die EXE liegt in: dist\AutoRecover.exe
pause

View File

@@ -0,0 +1,27 @@
"""Debug: Zeigt alle Elemente im Error-Fenster"""
import win32gui
def dump_window(hwnd, indent=0):
try:
class_name = win32gui.GetClassName(hwnd)
text = win32gui.GetWindowText(hwnd)
print(" " * indent + f'[{class_name}] "{text}" (hwnd={hwnd})')
except:
pass
try:
def callback(child_hwnd, _):
dump_window(child_hwnd, indent + 4)
win32gui.EnumChildWindows(hwnd, callback, None)
except:
pass
hwnd = win32gui.FindWindow(None, "Error")
if hwnd:
print(f"Fenster gefunden: hwnd={hwnd}\n")
dump_window(hwnd)
else:
print("Kein Fenster mit Titel 'Error' gefunden!")
print("Bitte Fehlerfenster öffnen und nochmal ausführen.")
input("\nEnter zum Beenden...")

View File

@@ -0,0 +1,46 @@
"""
Schritt 1: Fenster-Erkennung
Lass den Druckerfehler aufpoppen, dann starte dieses Skript.
Es zeigt alle sichtbaren Fenster und deren Buttons an.
"""
import win32gui
import win32con
import win32api
def get_child_controls(hwnd):
controls = []
def callback(child_hwnd, _):
class_name = win32gui.GetClassName(child_hwnd)
text = win32gui.GetWindowText(child_hwnd)
if class_name == "Button" and text:
controls.append(f' Button: "{text}"')
elif text:
controls.append(f' {class_name}: "{text}"')
win32gui.EnumChildWindows(hwnd, callback, None)
return controls
def list_windows():
windows = []
def callback(hwnd, _):
if win32gui.IsWindowVisible(hwnd):
title = win32gui.GetWindowText(hwnd)
if title:
controls = get_child_controls(hwnd)
windows.append((title, controls))
win32gui.EnumWindows(callback, None)
return windows
print("=" * 60)
print("Gefundene Fenster mit Buttons:")
print("=" * 60)
for title, controls in list_windows():
if controls:
print(f'\nFenster: "{title}"')
for c in controls:
print(c)
print("\n" + "=" * 60)
print("Kopiere den Fenstertitel und Button-Namen für AutoRecover.")
input("\nEnter drücken zum Beenden...")

View File

@@ -0,0 +1,67 @@
@echo off
title Skrift FTP-Checker – Einrichtung
color 0A
cd /d "%~dp0"
echo.
echo ============================================
echo Skrift FTP-Checker – Einrichtung
echo ============================================
echo.
:: Python pruefen
python --version >nul 2>&1
if errorlevel 1 (
color 0C
echo FEHLER: Python wurde nicht gefunden!
echo.
echo Bitte Python von https://python.org herunterladen,
echo installieren und dabei "Add Python to PATH" aktivieren.
echo.
pause
exit /b 1
)
python --version
echo Python gefunden.
echo.
:: Virtuelle Umgebung erstellen falls nicht vorhanden
if exist ".venv\Scripts\python.exe" (
echo Virtuelle Umgebung bereits vorhanden.
) else (
echo Erstelle virtuelle Umgebung...
python -m venv .venv
if errorlevel 1 (
color 0C
echo FEHLER: Konnte .venv nicht erstellen!
pause
exit /b 1
)
echo Fertig.
)
echo.
:: Abhaengigkeiten installieren
echo Installiere / aktualisiere Abhaengigkeiten...
.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check
if errorlevel 1 (
color 0C
echo.
echo FEHLER: Installation fehlgeschlagen!
echo Bitte Internetverbindung pruefen und erneut versuchen.
echo.
pause
exit /b 1
)
echo Fertig.
echo.
color 0A
echo ============================================
echo Einrichtung abgeschlossen!
echo ============================================
echo.
echo Doppelklick auf "Starten.vbs" zum Starten.
echo.
timeout /t 3 >nul

View File

@@ -0,0 +1,23 @@
@echo off
title Skrift FTP-Checker – Debug
cd /d "%~dp0"
:: Laufende Instanz auf Port 8765 beenden
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do (
taskkill /PID %%a /F >nul 2>&1
)
if exist ".venv\Scripts\python.exe" (
echo Starte mit .venv Python...
.venv\Scripts\python.exe main.py
) else (
echo HINWEIS: .venv nicht gefunden. Bitte zuerst Einrichten.bat ausfuehren.
echo Versuche System-Python...
python main.py
)
if errorlevel 1 (
echo.
echo Programm mit Fehler beendet. Siehe Meldung oben.
pause
)

View File

@@ -0,0 +1,40 @@
@echo off
title Skrift FTP-Checker
cd /d "%~dp0"
:: ── venv pruefen / erstellen ─────────────────────────────────────────
if exist ".venv\Scripts\python.exe" goto :deps
echo [Setup] Virtuelle Umgebung wird erstellt...
python -m venv .venv
if errorlevel 1 (
echo.
echo FEHLER: Python nicht gefunden!
echo Bitte Python von https://python.org installieren
echo und dabei "Add Python to PATH" aktivieren.
echo.
pause
exit /b 1
)
echo Erledigt.
:deps
:: ── Abhaengigkeiten installieren (immer) ─────────────────────────────
echo [Setup] Pakete werden geprueft / installiert...
.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check
if errorlevel 1 (
echo.
echo FEHLER: Pakete konnten nicht installiert werden.
pause
exit /b 1
)
:run
:: ── Alte Instanz auf Port 8765 beenden ───────────────────────────────
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do (
taskkill /PID %%a /F >nul 2>&1
)
:: ── App in eigenem Fenster starten (laeuft weiter wenn dieses Fenster zu) ──
echo Skrift FTP-Checker wird gestartet...
start "Skrift FTP-Checker" .venv\Scripts\python.exe main.py

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
from enum import Enum
class OrderStatus(str, Enum):
NEW = "new"
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
ERROR = "error"
ARCHIVED = "archived"
class MachineStatus(str, Enum):
IDLE = "Idle"
PRINTING = "Printing"
ERROR = "Error"
PAUSED = "Paused"
UNKNOWN = "Unknown"
MACHINE_ERROR_CODES = {
0: "Erfolgreich",
-1: "Parameterfehler",
-2: "Keine Maschine verfügbar",
-3: "Service beschäftigt",
1: "Fehler beim Generieren der Schreibdatei",
2: "Bitte erneut in der Software einloggen",
}

View File

@@ -0,0 +1,176 @@
import json
import os
import copy
from typing import Any
DEFAULT_CONFIG = {
"format_mapping": {
"letter": {
"A4": "a4_single",
"A6 Hochformat": "a6_v1",
"A6 Querformat": "a6_v2",
},
"envelope": {
"DIN Lang": "kuvert_a_v1",
"C6": "kuvert_b_v1",
},
},
"connection": {
"protocol": "sftp",
"host": "",
"port": 22,
"username": "",
"password": "",
"private_key_path": "",
"remote_path": "/orders/",
"poll_interval_seconds": 30,
},
"machine": {
"base_url": "http://192.168.2.32:90",
"username": "",
"password": "",
"mid": "",
"poll_interval_seconds": 5,
},
"templates": {
"a4_single": {
"name": "A4 Brief – Einzeln",
"width": 8.27,
"height": 11.69,
"xpos": 0.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0,
},
"a6_v1": {
"name": "A6 Brief – Variante 1",
"width": 4.1,
"height": 5.8,
"xpos": -0.3,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0,
},
"a6_v2": {
"name": "A6 Brief – Variante 2",
"width": 4.1,
"height": 5.8,
"xpos": 4.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 90,
},
"kuvert_a_v1": {
"name": "Kuvert A – Variante 1",
"width": 4.49,
"height": 6.38,
"xpos": 4.2,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90,
},
"kuvert_a_v2": {
"name": "Kuvert A – Variante 2",
"width": 4.33,
"height": 8.27,
"xpos": 4.0,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90,
},
"kuvert_b_v1": {
"name": "Kuvert B – Variante 1",
"width": 3.9,
"height": 8.27,
"xpos": 4.49,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90,
},
},
"app": {
"theme": "dark",
"local_temp_dir": "",
},
"machines": [],
}
class ConfigManager:
def __init__(self, config_path: str):
self._path = config_path
self._data: dict = {}
self._load()
def _load(self):
if os.path.exists(self._path):
with open(self._path, "r", encoding="utf-8") as f:
loaded = json.load(f)
self._data = self._deep_merge(copy.deepcopy(DEFAULT_CONFIG), loaded)
else:
self._data = copy.deepcopy(DEFAULT_CONFIG)
self._save()
def _save(self):
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(self._path, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2, ensure_ascii=False)
def _deep_merge(self, base: dict, override: dict) -> dict:
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._deep_merge(result[key], value)
else:
result[key] = value
return result
def get(self, key_path: str, default: Any = None) -> Any:
"""Access nested keys with dot notation: 'connection.host'"""
parts = key_path.split(".")
current = self._data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return default
return current
def set(self, key_path: str, value: Any):
"""Set nested key with dot notation and persist."""
parts = key_path.split(".")
current = self._data
for part in parts[:-1]:
current = current.setdefault(part, {})
current[parts[-1]] = value
self._save()
def get_section(self, section: str) -> dict:
return copy.deepcopy(self._data.get(section, {}))
def set_section(self, section: str, data: dict):
self._data[section] = data
self._save()
def get_templates(self) -> dict:
return copy.deepcopy(self._data.get("templates", {}))
def save_template(self, key: str, template: dict):
if "templates" not in self._data:
self._data["templates"] = {}
self._data["templates"][key] = template
self._save()
def delete_template(self, key: str):
self._data.get("templates", {}).pop(key, None)
self._save()
def get_local_temp_dir(self) -> str:
path = self.get("app.local_temp_dir") or os.path.join(
os.environ.get("TEMP", os.path.expanduser("~")), "ftp_checker"
)
os.makedirs(path, exist_ok=True)
return path
def reload(self):
self._load()

View File

@@ -0,0 +1,219 @@
import errno
import ftplib
import io
import json
import os
import stat
from typing import Optional
class FTPConnectionError(Exception):
pass
class ManifestNotFound(Exception):
"""Der Ordner enthält (noch) keine order-metadata.json – kein echter Fehler."""
def _is_missing_file(exc: Exception) -> bool:
"""Erkennt 'Datei existiert nicht' über SFTP (ENOENT) und FTP (550)."""
if isinstance(exc, FileNotFoundError):
return True
if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOENT:
return True
if isinstance(exc, ftplib.error_perm) and str(exc).startswith("550"):
return True
return False
class FTPClient:
"""
Protocol-agnostic FTP/SFTP client.
Uses paramiko for SFTP and ftplib for FTP.
"""
def __init__(self, config):
self._config = config
self._sftp = None
self._ssh = None
self._ftp: Optional[ftplib.FTP] = None
self._protocol: str = "sftp"
def connect(self):
self._protocol = self._config.get("connection.protocol", "sftp").lower()
host = self._config.get("connection.host", "")
port = int(self._config.get("connection.port", 22))
username = self._config.get("connection.username", "")
password = self._config.get("connection.password", "")
key_path = self._config.get("connection.private_key_path", "")
try:
if self._protocol == "sftp":
import paramiko
self._ssh = paramiko.SSHClient()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self._ssh.connect(
hostname=host,
port=port,
username=username,
password=password or None,
key_filename=key_path or None,
timeout=10,
)
self._sftp = self._ssh.open_sftp()
else:
ftp_port = int(self._config.get("connection.port", 21))
self._ftp = ftplib.FTP()
self._ftp.connect(host, ftp_port, timeout=10)
self._ftp.login(username, password)
except Exception as e:
raise FTPConnectionError(f"Verbindung fehlgeschlagen: {e}") from e
def disconnect(self):
try:
if self._sftp:
self._sftp.close()
if self._ssh:
self._ssh.close()
if self._ftp:
self._ftp.quit()
except Exception:
pass
finally:
self._sftp = None
self._ssh = None
self._ftp = None
def is_connected(self) -> bool:
try:
if self._protocol == "sftp":
if self._sftp is None:
return False
self._sftp.listdir(".")
return True
else:
if self._ftp is None:
return False
self._ftp.voidcmd("NOOP")
return True
except Exception:
return False
def list_order_folders(self) -> list[str]:
remote_path = self._config.get("connection.remote_path", "/orders/")
try:
if self._protocol == "sftp":
entries = self._sftp.listdir_attr(remote_path)
return [
e.filename
for e in entries
if stat.S_ISDIR(e.st_mode)
]
else:
original = self._ftp.pwd()
self._ftp.cwd(remote_path)
folders = []
lines = []
self._ftp.retrlines("LIST", lines.append)
for line in lines:
parts = line.split()
if parts and parts[0].startswith("d"):
name = parts[-1]
if name not in (".", ".."):
folders.append(name)
self._ftp.cwd(original)
return folders
except Exception as e:
raise FTPConnectionError(f"Ordner auflisten fehlgeschlagen: {e}") from e
def list_folder_files(self, folder: str, recursive: bool = False) -> list[dict]:
"""
Listet Dateien im Auftragsordner auf. Mit ``recursive=True`` werden auch
Dateien in Unterordnern (z.B. 'umschlaege/') aufgenommen; ``name`` enthält
dann den relativen Pfad (z.B. 'umschlaege/kuvert_000.svg').
"""
remote_path = self._config.get("connection.remote_path", "/orders/")
folder_path = f"{remote_path.rstrip('/')}/{folder}"
try:
if self._protocol == "sftp":
return self._list_sftp(folder_path, "", recursive)
else:
return self._list_ftp(folder_path, "", recursive)
except Exception as e:
raise FTPConnectionError(f"Dateiliste fehlgeschlagen ({folder}): {e}") from e
def _list_sftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]:
out = []
for e in self._sftp.listdir_attr(base_path):
rel = f"{prefix}{e.filename}"
if stat.S_ISDIR(e.st_mode):
if recursive:
out.extend(self._list_sftp(f"{base_path}/{e.filename}", rel + "/", True))
else:
out.append({"name": rel, "size": e.st_size or 0})
return out
def _list_ftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]:
original = self._ftp.pwd()
self._ftp.cwd(base_path)
lines = []
self._ftp.retrlines("LIST", lines.append)
self._ftp.cwd(original)
out = []
for line in lines:
parts = line.split()
if not parts:
continue
name = parts[-1]
if name in (".", ".."):
continue
if parts[0].startswith("d"):
if recursive:
out.extend(self._list_ftp(f"{base_path}/{name}", f"{prefix}{name}/", True))
else:
try:
size = int(parts[4])
except (IndexError, ValueError):
size = 0
out.append({"name": f"{prefix}{name}", "size": size})
return out
def read_order_manifest(self, folder: str) -> dict:
remote_path = self._config.get("connection.remote_path", "/orders/")
manifest_path = f"{remote_path.rstrip('/')}/{folder}/order-metadata.json"
try:
if self._protocol == "sftp":
with self._sftp.open(manifest_path, "r") as f:
return json.loads(f.read())
else:
buf = io.BytesIO()
self._ftp.retrbinary(f"RETR {manifest_path}", buf.write)
return json.loads(buf.getvalue().decode("utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"Ungültiges JSON in {manifest_path}: {e}") from e
except Exception as e:
# Fehlende Datei ist der Normalfall (Ordner noch im Upload o.Ä.) –
# gesondert melden, damit der Poller nicht jeden Zyklus warnt.
if _is_missing_file(e):
raise ManifestNotFound(folder) from e
raise FTPConnectionError(f"Manifest lesen fehlgeschlagen: {e}") from e
def download_svg(self, folder: str, filename: str, local_dir: str) -> str:
"""
Lädt eine SVG-Datei herunter. filename kann einen Unterordnerpfad
enthalten (z.B. 'umschlaege/kuvert_000.svg') — lokal wird nur der
Dateiname (ohne Unterordner) gespeichert.
"""
remote_path = self._config.get("connection.remote_path", "/orders/")
remote_file = f"{remote_path.rstrip('/')}/{folder}/{filename}"
os.makedirs(local_dir, exist_ok=True)
local_path = os.path.join(local_dir, os.path.basename(filename))
try:
if self._protocol == "sftp":
self._sftp.get(remote_file, local_path)
else:
with open(local_path, "wb") as f:
self._ftp.retrbinary(f"RETR {remote_file}", f.write)
return local_path
except Exception as e:
raise FTPConnectionError(f"Download fehlgeschlagen ({filename}): {e}") from e

View File

@@ -0,0 +1,182 @@
import threading
from typing import Optional
import requests
from app.constants import MACHINE_ERROR_CODES, MachineStatus
class MachineAPIError(Exception):
def __init__(self, code: int, message: str = ""):
self.code = code
self.message = message or MACHINE_ERROR_CODES.get(code, f"Unbekannter Fehler (Code {code})")
super().__init__(self.message)
class MachineAPIClient:
def __init__(self, config, machine_override: dict = None):
self._config = config
self._override = machine_override or {}
self._session = requests.Session()
self._lock = threading.Lock()
@property
def _base_url(self) -> str:
if self._override:
return self._override.get("base_url", "").rstrip("/")
return self._config.get("machine.base_url", "").rstrip("/")
def login(self) -> bool:
if self._override:
username = self._override.get("username", "")
password = self._override.get("password", "")
else:
username = self._config.get("machine.username", "")
password = self._config.get("machine.password", "")
if not username:
return True # No login required
with self._lock:
resp = self._session.get(
f"{self._base_url}/login",
params={"uin": username, "passwd": password},
timeout=10,
)
data = resp.json()
return data.get("error", -1) == 0
def get_status(self) -> list[dict]:
"""Returns list of machine status dicts."""
if self._override:
mid = self._override.get("mid", "")
else:
mid = self._config.get("machine.mid", "")
params = {}
if mid:
params["mids"] = mid
with self._lock:
resp = self._session.get(
f"{self._base_url}/machine_status",
params=params,
timeout=10,
)
data = resp.json()
if isinstance(data, list):
return data
return []
def get_first_status(self) -> Optional[dict]:
statuses = self.get_status()
return statuses[0] if statuses else None
def is_idle(self) -> bool:
status = self.get_first_status()
if status is None:
return False
return status.get("status") == MachineStatus.IDLE
def recover_write(self, mid: str = "") -> bool:
"""Setzt einen bereits in der Maschine liegenden Schreib-Task nach einem
Fehler fort ('Feed again' / recover_write).
WICHTIG: Es wird KEINE Datei neu hochgeladen – der vorhandene Task wird
nur neu zugeführt/fortgesetzt. Gibt True zurück, wenn die Maschine den
Recovery bestätigt ({"code":0}).
"""
if self._override:
effective_mid = mid or self._override.get("mid", "")
else:
effective_mid = mid or self._config.get("machine.mid", "")
params = {}
if effective_mid:
params["mid"] = effective_mid
with self._lock:
resp = self._session.get(
f"{self._base_url}/recover_write",
params=params,
timeout=10,
)
try:
data = resp.json()
except Exception:
raise MachineAPIError(-1, f"Ungültige recover_write-Antwort (HTTP {resp.status_code}): {resp.text[:200]}")
return data.get("code", -1) == 0
def send_svgs(
self,
svg_paths: list[str],
width: float,
height: float,
xpos: float = 0,
ypos: float = 0,
rotation: float = 0,
scale: float = 1.0,
mid: str = "",
clear: bool = True,
start: bool = True,
repeat: int = 1,
) -> dict:
"""
Upload one or more SVG files in a single multipart POST.
All files share the same width/height/xpos/ypos/rotation/scale.
"""
def _fmt(v: float) -> str:
"""Format float without trailing .0 so the machine API gets '90' not '90.0'."""
return str(int(v)) if float(v) == int(v) else str(v)
data = {
"width": f"{_fmt(width)}in",
"height": f"{_fmt(height)}in",
"xpos": f"{_fmt(xpos)}in",
"ypos": f"{_fmt(ypos)}in",
"rotation": _fmt(rotation),
"scale": _fmt(scale),
"clear": "1" if clear else "0",
"start": "1" if start else "0",
"repeat": str(repeat),
}
if self._override:
effective_mid = mid or self._override.get("mid", "")
else:
effective_mid = mid or self._config.get("machine.mid", "")
if effective_mid:
data["mid"] = effective_mid
import os, logging
_log = logging.getLogger(__name__)
last = len(svg_paths) - 1
result = {}
for i, path in enumerate(svg_paths):
file_data = {
**data,
"clear": "1" if i == 0 else "0",
"start": "1" if i == last else "0",
}
_log.info(
"write_svg [%d/%d] %s | params: %s",
i + 1, last + 1, os.path.basename(path),
{k: v for k, v in file_data.items() if k != "file"},
)
with open(path, "rb") as h:
files = [("file", (os.path.basename(path), h, "image/svg+xml"))]
with self._lock:
resp = self._session.post(
f"{self._base_url}/write_svg",
data=file_data,
files=files,
timeout=60,
)
try:
result = resp.json()
except Exception:
raise MachineAPIError(-1, f"Ungültige API-Antwort (HTTP {resp.status_code}): {resp.text[:200]}")
_log.info("write_svg response [%d/%d]: %s", i + 1, last + 1, result)
if result.get("error", -1) != 0:
err_code = result["error"]
raise MachineAPIError(
err_code,
f"Maschine meldet Fehler {err_code} bei Datei '{os.path.basename(path)}' "
f"(params: width={data['width']} height={data['height']} "
f"xpos={data['xpos']} ypos={data['ypos']} "
f"rotation={data['rotation']} scale={data['scale']})"
)
return result

View File

@@ -0,0 +1,390 @@
import json
import os
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from app.constants import OrderStatus
@dataclass
class Order:
order_number: str
session_id: str
finalized_at: str
letter_count: int
envelope_count: int
letters: list
envelopes: list
letter_format: str
envelope_format: str
folder_name: str
local_dir: str
envelope_beschriftung: str = ""
status: OrderStatus = OrderStatus.NEW
error_message: Optional[str] = None
letter_params: Optional[dict] = None
envelope_params: Optional[dict] = None
split_size: int = 0 # 0 = no split
queue_send_letters: bool = True # in der Warteschlange: Briefe senden?
queue_send_envelopes: bool = True # in der Warteschlange: Kuverts senden?
queue_seq: float = 0.0 # Abarbeitungsreihenfolge der Warteschlange (kleiner = früher)
letter_print_log: list = field(default_factory=list) # [{"from":1,"to":10,"at":"ISO"}]
envelope_print_log: list = field(default_factory=list)
comment: str = ""
has_manifest: bool = True # False = Ordner ohne order-metadata.json (nur Dateien)
class OrderManager:
def __init__(self, processed_path: str, temp_base_dir: str, archive_path: str = None):
self._processed_path = processed_path
self._temp_base_dir = temp_base_dir
self._archive_path = archive_path or os.path.join(
os.path.dirname(processed_path), "archive.json"
)
self._order_data_path = os.path.join(
os.path.dirname(processed_path), "order_data.json"
)
self._orders: dict[str, Order] = {}
self._processed: set[str] = set()
self._archived_orders: dict[str, dict] = {}
self._order_data: dict[str, dict] = {} # persistent per-order data
self._lock = threading.Lock()
self._load_processed()
self._load_archive()
self._load_order_data()
def _load_processed(self):
if os.path.exists(self._processed_path):
try:
with open(self._processed_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._processed = set(data.get("processed", []))
except Exception:
self._processed = set()
def _save_processed(self):
os.makedirs(os.path.dirname(self._processed_path), exist_ok=True)
with open(self._processed_path, "w", encoding="utf-8") as f:
json.dump(
{
"processed": sorted(self._processed),
"last_updated": datetime.utcnow().isoformat() + "Z",
},
f,
indent=2,
)
def _load_order_data(self):
if os.path.exists(self._order_data_path):
try:
with open(self._order_data_path, "r", encoding="utf-8") as f:
self._order_data = json.load(f)
except Exception:
self._order_data = {}
def _save_order_data(self):
# Merge current in-memory orders into persistent store
for num, order in self._orders.items():
self._order_data[num] = {
"comment": order.comment,
"letter_print_log": order.letter_print_log,
"envelope_print_log": order.envelope_print_log,
}
try:
os.makedirs(os.path.dirname(self._order_data_path), exist_ok=True)
with open(self._order_data_path, "w", encoding="utf-8") as f:
json.dump(self._order_data, f, indent=2, ensure_ascii=False)
except Exception:
pass
def _apply_order_data(self, order: "Order"):
"""Merge persisted per-order data (comment, print log) into an Order object."""
stored = self._order_data.get(order.order_number, {})
if stored.get("comment"):
order.comment = stored["comment"]
if stored.get("letter_print_log"):
order.letter_print_log = list(stored["letter_print_log"])
if stored.get("envelope_print_log"):
order.envelope_print_log = list(stored["envelope_print_log"])
def _load_archive(self):
if os.path.exists(self._archive_path):
try:
with open(self._archive_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._archived_orders = {
entry["order_number"]: entry
for entry in data.get("archived", [])
if "order_number" in entry
}
except Exception:
self._archived_orders = {}
def _save_archive(self):
os.makedirs(os.path.dirname(self._archive_path), exist_ok=True)
with open(self._archive_path, "w", encoding="utf-8") as f:
json.dump(
{
"archived": list(self._archived_orders.values()),
"last_updated": datetime.utcnow().isoformat() + "Z",
},
f,
indent=2,
ensure_ascii=False,
)
def archive(self, order_number: str):
"""Move an order to the archive. Saves minimal metadata to archive file."""
with self._lock:
order = self._orders.get(order_number)
if not order:
return
metadata = {
"order_number": order.order_number,
"folder_name": order.folder_name,
"finalized_at": order.finalized_at,
"letter_count": order.letter_count,
"envelope_count": order.envelope_count,
"letter_format": order.letter_format,
"envelope_format": order.envelope_format,
"envelope_beschriftung": order.envelope_beschriftung,
"archived_at": datetime.utcnow().isoformat() + "Z",
}
self._archived_orders[order_number] = metadata
del self._orders[order_number]
self._save_archive()
def unarchive(self, order_number: str) -> bool:
"""Restore an archived order back to active status (NEW)."""
with self._lock:
metadata = self._archived_orders.get(order_number)
if not metadata:
return False
# Remove from archive + processed
del self._archived_orders[order_number]
self._processed.discard(order_number)
# Re-create a minimal Order object so it shows immediately
temp_dir = os.path.join(self._temp_base_dir, order_number)
order = Order(
order_number=metadata["order_number"],
session_id="",
finalized_at=metadata.get("finalized_at", ""),
letter_count=metadata.get("letter_count", 0),
envelope_count=metadata.get("envelope_count", 0),
letters=[],
envelopes=[],
letter_format=metadata.get("letter_format", ""),
envelope_format=metadata.get("envelope_format", ""),
folder_name=metadata.get("folder_name", ""),
local_dir=temp_dir,
envelope_beschriftung=metadata.get("envelope_beschriftung", ""),
status=OrderStatus.NEW,
)
self._apply_order_data(order)
self._orders[order_number] = order
self._save_archive()
self._save_processed()
return True
def get_archived(self) -> list[dict]:
"""Return archived order metadata, sorted by finalized_at descending."""
with self._lock:
items = list(self._archived_orders.values())
items.sort(key=lambda x: x.get("finalized_at", ""), reverse=True)
return items
def _apply_manifest_fields(self, order: "Order", folder: str, manifest: dict):
"""Populate/refresh an order's content fields from its manifest."""
order.session_id = manifest.get("sessionId") or ""
order.finalized_at = manifest.get("finalizedAt") or ""
order.letter_count = manifest.get("letterCount") or 0
order.envelope_count = manifest.get("envelopeCount") or 0
order.letters = manifest.get("letters") or []
order.envelopes = manifest.get("envelopes") or []
order.letter_format = manifest.get("letterFormat") or ""
order.envelope_format = manifest.get("envelopeFormat") or ""
order.envelope_beschriftung = manifest.get("envelopeBeschriftung") or ""
order.folder_name = folder
order.has_manifest = True
def update_from_remote(
self,
folders: list[str],
manifests: dict[str, dict],
no_manifest_folders: set[str] = None,
) -> list[Order]:
"""
Merge newly fetched folder data. Returns list of newly discovered orders.
Ordner mit Manifest werden wie gewohnt angelegt. Ordner in
``no_manifest_folders`` (bestätigt ohne order-metadata.json) erscheinen
mit dem Ordnernamen als Auftragsnummer und ohne SVG-Liste. Taucht später
ein Manifest auf, wird der bestehende Auftrag aufgewertet.
"""
no_manifest_folders = no_manifest_folders or set()
new_orders = []
with self._lock:
for folder in folders:
manifest = manifests.get(folder)
if manifest:
order_number = manifest.get("orderNumber") or folder
existing = self._orders.get(order_number)
if existing:
# Bereits bekannt – nur einen Manifest-losen Stub aufwerten.
if not existing.has_manifest:
self._apply_manifest_fields(existing, folder, manifest)
continue
if order_number in self._archived_orders:
continue # Already archived
local_dir = os.path.join(self._temp_base_dir, order_number)
already_sent = order_number in self._processed
order = Order(
order_number=order_number,
session_id="",
finalized_at="",
letter_count=0,
envelope_count=0,
letters=[],
envelopes=[],
letter_format="",
envelope_format="",
folder_name=folder,
local_dir=local_dir,
status=OrderStatus.SENT if already_sent else OrderStatus.NEW,
)
self._apply_manifest_fields(order, folder, manifest)
self._apply_order_data(order)
self._orders[order_number] = order
if not already_sent:
new_orders.append(order)
elif folder in no_manifest_folders:
# Ordner ohne Manifest: Ordnername = Auftragsnummer.
order_number = folder
if order_number in self._orders:
continue
if order_number in self._archived_orders:
continue
local_dir = os.path.join(self._temp_base_dir, order_number)
already_sent = order_number in self._processed
order = Order(
order_number=order_number,
session_id="",
finalized_at="",
letter_count=0,
envelope_count=0,
letters=[],
envelopes=[],
letter_format="",
envelope_format="",
folder_name=folder,
local_dir=local_dir,
status=OrderStatus.SENT if already_sent else OrderStatus.NEW,
has_manifest=False,
)
self._apply_order_data(order)
self._orders[order_number] = order
if not already_sent:
new_orders.append(order)
return new_orders
def get_all(self) -> list[Order]:
with self._lock:
return sorted(
self._orders.values(),
key=lambda o: o.finalized_at,
reverse=True,
)
def get_by_number(self, order_number: str) -> Optional[Order]:
with self._lock:
return self._orders.get(order_number)
def update_status(self, order_number: str, status: OrderStatus, error: str = None):
with self._lock:
order = self._orders.get(order_number)
if order:
order.status = status
order.error_message = error
def mark_processed(self, order_number: str):
with self._lock:
order = self._orders.get(order_number)
if order:
order.status = OrderStatus.SENT
order.error_message = None
self._processed.add(order_number)
self._save_processed()
def is_processed(self, order_number: str) -> bool:
with self._lock:
return order_number in self._processed
def get_queued(self) -> list[Order]:
with self._lock:
return sorted(
[o for o in self._orders.values() if o.status == OrderStatus.QUEUED],
key=lambda o: (o.queue_seq, o.finalized_at),
)
def add_print_log(self, order_number: str, kind: str, from_idx: int, to_idx: int):
"""Record a printed range (0-based, inclusive) for 'letter' or 'envelope'."""
with self._lock:
order = self._orders.get(order_number)
if not order:
return
entry = {"from": from_idx, "to": to_idx, "at": datetime.utcnow().isoformat() + "Z"}
if kind == "letter":
order.letter_print_log.append(entry)
elif kind == "envelope":
order.envelope_print_log.append(entry)
self._save_order_data()
def add_print_indices(self, order_number: str, kind: str, indices: list):
"""
Record printed 0-based indices (Briefnummern) for 'letter' or 'envelope'.
Aufeinanderfolgende Nummern werden zu {from,to}-Bereichen komprimiert.
"""
idx = sorted({int(i) for i in indices})
if not idx:
return
ranges = []
start = prev = idx[0]
for i in idx[1:]:
if i == prev + 1:
prev = i
else:
ranges.append((start, prev))
start = prev = i
ranges.append((start, prev))
now = datetime.utcnow().isoformat() + "Z"
with self._lock:
order = self._orders.get(order_number)
if not order:
return
log = order.letter_print_log if kind == "letter" else order.envelope_print_log
for a, b in ranges:
log.append({"from": a, "to": b, "at": now})
self._save_order_data()
def set_comment(self, order_number: str, comment: str):
"""Save a comment for an order."""
with self._lock:
order = self._orders.get(order_number)
if order:
order.comment = comment
if order_number not in self._order_data:
self._order_data[order_number] = {}
self._order_data[order_number]["comment"] = comment
self._save_order_data()
def set_order_params(self, order_number: str, **kwargs):
"""Update per-order UI parameters (template key, overrides, split size)."""
with self._lock:
order = self._orders.get(order_number)
if not order:
return
for k, v in kwargs.items():
if hasattr(order, k):
setattr(order, k, v)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
import threading
import time
from typing import Callable, Optional
from app.constants import MachineStatus
from app.core.machine_client import MachineAPIClient
class MachinePollWorker(threading.Thread):
def __init__(
self,
config,
machine_client: MachineAPIClient,
on_status_update: Callable,
on_log: Callable,
on_idle_trigger: Callable,
):
super().__init__(daemon=True)
self._config = config
self._machine = machine_client
self._on_status_update = on_status_update # called with dict or None
self._on_log = on_log
self._on_idle_trigger = on_idle_trigger # called when machine becomes Idle
self._stop_event = threading.Event()
self._last_status: Optional[str] = None
# --- Auto-Recovery bei Fehler während des Drucks ---
# Versucht max. N-mal recover_write ("Feed again"), OHNE den Auftrag
# neu zu senden. Wird nur ausgelöst, wenn die Maschine aus dem Zustand
# "Printing" heraus auf "Error" wechselt (= Fehler während des Drucks).
self._max_retries = config.get("machine.error_max_retries", 3)
self._retry_delay = config.get("machine.error_retry_delay_seconds", 5)
self._error_active = False # läuft gerade eine Recovery-Episode?
self._error_retries = 0 # bereits unternommene recover_write-Versuche
self._last_retry_ts = 0.0 # Zeitstempel des letzten Versuchs (monotonic)
def stop(self):
self._stop_event.set()
def run(self):
interval = self._config.get("machine.poll_interval_seconds", 5)
while not self._stop_event.wait(timeout=interval):
interval = self._config.get("machine.poll_interval_seconds", 5)
self._poll()
def _poll(self):
try:
status_list = self._machine.get_status()
if status_list:
status = status_list[0]
self._on_status_update(status)
current = status.get("status", MachineStatus.UNKNOWN)
if self._last_status != MachineStatus.IDLE and current == MachineStatus.IDLE:
self._on_idle_trigger()
self._handle_error_recovery(self._last_status, current, status)
self._last_status = current
else:
self._on_status_update(None)
self._last_status = None
except Exception as e:
self._on_log(f"Maschinen-Status Fehler: {e}", "error")
self._on_status_update(None)
def _handle_error_recovery(self, prev: Optional[str], current: str, status: dict):
"""Automatischer recover_write-Retry bei Fehler während des Drucks.
Sendet NICHTS neu – ruft nur recover_write auf, um den bereits in der
Maschine liegenden Task fortzusetzen. Max. self._max_retries Versuche
mit je self._retry_delay Sekunden Abstand.
"""
if current == MachineStatus.ERROR:
# Episode nur starten, wenn der Fehler aus laufendem Druck kam
if not self._error_active and prev == MachineStatus.PRINTING:
self._error_active = True
self._error_retries = 0
self._last_retry_ts = 0.0
self._on_log(
"Maschine meldet Fehler während des Drucks – starte automatische "
f"Wiederaufnahme (max. {self._max_retries} Versuche, kein Neu-Senden).",
"warning",
)
if self._error_active and self._error_retries < self._max_retries:
now = time.monotonic()
if now - self._last_retry_ts >= self._retry_delay:
self._error_retries += 1
self._last_retry_ts = now
mid = status.get("mid", "")
self._on_log(
f"Wiederaufnahme-Versuch {self._error_retries}/{self._max_retries} "
f"(recover_write)…", "info",
)
try:
ok = self._machine.recover_write(mid)
if ok:
self._on_log(
f"recover_write bestätigt (Versuch {self._error_retries}/{self._max_retries}).",
"info",
)
else:
self._on_log(
f"recover_write nicht bestätigt (Versuch {self._error_retries}/{self._max_retries}).",
"warning",
)
except Exception as e:
self._on_log(f"recover_write Fehler: {e}", "error")
elif self._error_active and self._error_retries >= self._max_retries:
# Alle Versuche ausgeschöpft, Maschine immer noch im Fehler
self._on_log(
f"Automatische Wiederaufnahme nach {self._max_retries} Versuchen "
"fehlgeschlagen – bitte Maschine manuell prüfen.", "error",
)
self._error_active = False # nicht weiter versuchen (re-armt erst bei erneutem Druck)
else:
# Maschine hat den Fehlerzustand verlassen
if self._error_active and current in (MachineStatus.PRINTING, MachineStatus.IDLE):
if self._error_retries > 0:
self._on_log("Maschine hat sich erholt – Druck wird fortgesetzt.", "success")
self._error_active = False
self._error_retries = 0
self._last_retry_ts = 0.0

View File

@@ -0,0 +1,183 @@
import logging
import threading
from typing import Callable
from app.core.ftp_client import FTPClient, FTPConnectionError, ManifestNotFound
from app.core.order_manager import OrderManager
log = logging.getLogger(__name__)
class FTPPollWorker(threading.Thread):
def __init__(
self,
config,
order_manager: OrderManager,
on_new_orders: Callable,
on_log: Callable,
on_connection_change: Callable,
):
super().__init__(daemon=True)
self._config = config
self._order_manager = order_manager
self._on_new_orders = on_new_orders # called with list[Order]
self._on_log = on_log # called with (message, level)
self._on_connection_change = on_connection_change # called with bool
self._stop_event = threading.Event()
self._ftp = FTPClient(config)
self._connected = False
# Anti-Spam: pro Ordner die zuletzt gemeldete Hinweis-Signatur merken,
# damit dieselbe Meldung nicht bei jedem Poll erneut im Log landet.
self._folder_notices: dict[str, str] = {}
def stop(self):
self._stop_event.set()
def run(self):
self._poll() # Immediate first poll
interval = self._config.get("connection.poll_interval_seconds", 30)
while not self._stop_event.wait(timeout=interval):
interval = self._config.get("connection.poll_interval_seconds", 30)
self._poll()
def _poll(self):
try:
if not self._ftp.is_connected():
self._ftp.disconnect()
self._ftp.connect()
if not self._connected:
self._connected = True
self._on_connection_change(True)
self._on_log("FTP-Verbindung hergestellt.", "info")
folders = self._ftp.list_order_folders()
# Hinweise für verschwundene Ordner vergessen (damit sie bei
# erneutem Auftauchen wieder einmalig gemeldet werden).
present = set(folders)
for gone in [f for f in self._folder_notices if f not in present]:
self._folder_notices.pop(gone, None)
manifests = {}
no_manifest_folders: set[str] = set()
for folder in folders:
try:
manifest = self._ftp.read_order_manifest(folder)
# Kuverts liegen im Unterordner 'umschlaege/' — Pfad voraussetzen
manifest["envelopes"] = [
f"umschlaege/{f}" for f in manifest.get("envelopes", [])
]
manifests[folder] = manifest
self._folder_notices.pop(folder, None) # Ordner ok → Hinweis löschen
except ManifestNotFound:
# Ordner ohne order-metadata.json: trotzdem als Auftrag
# anzeigen (Ordnername = Auftragsnummer, alle Dateien).
no_manifest_folders.add(folder)
if self._folder_notices.get(folder) != "missing":
self._folder_notices[folder] = "missing"
self._on_log(
f"Ordner {folder}: kein order-metadata.json – "
"wird ohne SVG-Zuordnung angezeigt.", "info"
)
except (ValueError, FTPConnectionError) as e:
# Echte Fehler (kaputtes JSON, Verbindungsproblem) weiterhin
# als Warnung – aber pro Ordner nur einmal je Meldung.
# Nicht als "manifestlos" anzeigen, da das Manifest existiert.
if self._folder_notices.get(folder) != str(e):
self._folder_notices[folder] = str(e)
self._on_log(f"Manifest übersprungen ({folder}): {e}", "warning")
new_orders = self._order_manager.update_from_remote(
folders, manifests, no_manifest_folders
)
if new_orders:
self._on_log(f"{len(new_orders)} neuer Auftrag/Aufträge gefunden.", "info")
self._on_new_orders(new_orders)
except FTPConnectionError as e:
self._connected = False
self._on_connection_change(False)
self._on_log(f"FTP-Fehler: {e}", "error")
except Exception as e:
self._on_log(f"Unbekannter Poll-Fehler: {e}", "error")
def reconnect(self):
"""Force reconnect on next poll (e.g., after settings change)."""
self._ftp.disconnect()
self._connected = False
def process_queue_tick():
"""
Startet den nächsten Warteschlangen-Auftrag, sobald die Maschine frei ist.
Läuft ohne manuelle Druckbestätigung: der Auftrag wird nach der Übertragung
automatisch abgeschlossen (siehe _run_send_job), sodass der nächste Auftrag
startet, sobald die Maschine wieder Idle meldet.
Wiederverwendbar: vom QueueWorker-Loop und vom /api/queue/kick-Endpoint.
"""
# Lazy import to avoid circular dependency
from app.api.routes import _state, _run_send_job
# Skip wenn schon ein Job läuft
if _state.get("current_order"):
return
# Skip wenn Formatwechsel-Bestätigung aussteht (physischer Materialwechsel)
if _state.get("format_confirm_event"):
return
om = _state.get("om")
machine = _state.get("machine")
if not om or not machine:
return
# Prüfen ob Maschine bereit ist
if not machine.is_idle():
return
# Nächsten Auftrag aus der Warteschlange holen
queued = om.get_queued()
if not queued:
return
next_order = queued[0]
# Gespeicherte Druckparameter verwenden
zero = {"width": 0, "height": 0, "xpos": 0, "ypos": 0, "scale": 1, "rotation": 0}
letter_params = next_order.letter_params or dict(zero)
envelope_params = next_order.envelope_params or dict(zero)
# Typ-Auswahl der Warteschlange respektieren (Briefe- oder Kuvert-Sammeldruck)
send_letters = getattr(next_order, "queue_send_letters", True) and bool(next_order.letters)
send_envelopes = getattr(next_order, "queue_send_envelopes", True) and bool(next_order.envelopes)
log.info(f"Queue-Worker: Starte Auftrag {next_order.order_number}")
t = threading.Thread(
target=_run_send_job,
args=(next_order, letter_params, envelope_params),
kwargs={"send_letters": send_letters, "send_envelopes": send_envelopes},
daemon=True,
)
t.start()
class QueueWorker(threading.Thread):
"""Verarbeitet automatisch warteschlange-Aufträge wenn die Maschine bereit ist."""
INTERVAL = 6 # Sekunden zwischen Prüfungen
def __init__(self):
super().__init__(daemon=True)
self._stop_event = threading.Event()
def stop(self):
self._stop_event.set()
def run(self):
while not self._stop_event.wait(timeout=self.INTERVAL):
try:
process_queue_tick()
except Exception as e:
log.warning(f"QueueWorker Fehler: {e}")

View File

@@ -0,0 +1,117 @@
{
"connection": {
"protocol": "sftp",
"host": "192.168.1.100",
"port": 22,
"username": "skrift",
"password": "",
"private_key_path": "",
"remote_path": "/orders/",
"poll_interval_seconds": 30
},
"machine": {
"base_url": "http://192.168.2.32:90",
"username": "",
"password": "",
"mid": "",
"poll_interval_seconds": 5
},
"format_mapping": {
"letter": {
"A4": "a4_v1",
"A6 Hochformat": "a6p_v1",
"A6 Querformat": "a6l_v1"
},
"envelope": {
"DIN Lang": "dinlang_v1",
"C6": "c6_v1"
}
},
"templates": {
"a4_v1": {
"name": "A4 – Variante 1",
"width": 8.27,
"height": 11.69,
"xpos": 0.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0
},
"a6p_v1": {
"name": "A6 Hochformat – Variante 1",
"width": 4.1,
"height": 5.8,
"xpos": -0.3,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0
},
"a6p_v2": {
"name": "A6 Hochformat – Variante 2",
"width": 4.1,
"height": 5.8,
"xpos": 4.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 90
},
"a6l_v1": {
"name": "A6 Querformat – Variante 1",
"width": 5.8,
"height": 4.1,
"xpos": -0.3,
"ypos": -0.3,
"scale": 1.0,
"rotation": 0
},
"a6l_v2": {
"name": "A6 Querformat – Variante 2",
"width": 5.8,
"height": 4.1,
"xpos": 4.0,
"ypos": -0.3,
"scale": 1.0,
"rotation": 90
},
"dinlang_v1": {
"name": "DIN Lang Kuvert – Variante 1",
"width": 4.33,
"height": 8.27,
"xpos": 4.0,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
},
"dinlang_v2": {
"name": "DIN Lang Kuvert – Variante 2",
"width": 3.9,
"height": 8.27,
"xpos": 4.49,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
},
"c6_v1": {
"name": "C6 Kuvert – Variante 1",
"width": 4.49,
"height": 6.38,
"xpos": 4.2,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
},
"c6_v2": {
"name": "C6 Kuvert – Variante 2",
"width": 4.33,
"height": 6.38,
"xpos": 4.0,
"ypos": -0.2,
"scale": 1.0,
"rotation": 90
}
},
"app": {
"theme": "dark",
"local_temp_dir": ""
}
}

View File

@@ -0,0 +1,125 @@
import asyncio
import os
import sys
import threading
import webbrowser
import uvicorn
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
sys.path.insert(0, os.path.dirname(__file__))
from app.core.config_manager import ConfigManager
from app.core.machine_client import MachineAPIClient
from app.core.order_manager import OrderManager
from app.workers.poll_worker import FTPPollWorker, QueueWorker
from app.workers.machine_poll_worker import MachinePollWorker
from app.api import routes
from app.api.routes import push_log, push_machine_status, push_new_orders, push_ftp_status
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
PROCESSED_PATH = os.path.join(os.path.dirname(__file__), "processed_orders.json")
ARCHIVE_PATH = os.path.join(os.path.dirname(__file__), "archive.json")
STATIC_DIR = os.path.join(os.path.dirname(__file__), "app", "static")
PORT = 8765
def create_app():
config = ConfigManager(CONFIG_PATH)
order_manager = OrderManager(
processed_path=PROCESSED_PATH,
temp_base_dir=config.get_local_temp_dir(),
archive_path=ARCHIVE_PATH,
)
machine_client = MachineAPIClient(config)
app = FastAPI(title="Skrift FTP-Checker")
# SSE event queue (created inside the async loop)
event_queue: asyncio.Queue = None
loop_holder = {}
@app.on_event("startup")
async def startup():
nonlocal event_queue
event_queue = asyncio.Queue()
loop = asyncio.get_event_loop()
loop_holder["loop"] = loop
ftp_worker = FTPPollWorker(
config=config,
order_manager=order_manager,
on_new_orders=lambda orders: (push_new_orders(), None),
on_log=push_log,
on_connection_change=push_ftp_status,
)
machine_worker = MachinePollWorker(
config=config,
machine_client=machine_client,
on_status_update=push_machine_status,
on_log=push_log,
on_idle_trigger=_on_machine_idle,
)
routes.init(
config=config,
order_manager=order_manager,
machine_client=machine_client,
ftp_worker=ftp_worker,
machine_worker=machine_worker,
event_queue=event_queue,
)
routes._state["loop"] = loop
routes._state["auto_queue"] = False
routes._state["cancel_event"] = None
routes._state["current_order"] = None
queue_worker = QueueWorker()
ftp_worker.start()
machine_worker.start()
queue_worker.start()
# Login attempt
threading.Thread(target=_do_login, args=(machine_client,), daemon=True).start()
# Open browser after short delay
threading.Timer(1.2, lambda: webbrowser.open(f"http://localhost:{PORT}")).start()
def _on_machine_idle():
pass # Queue-Verarbeitung übernimmt QueueWorker
def _do_login(machine):
try:
ok = machine.login()
push_log("Maschinen-Login erfolgreich." if ok else "Maschinen-Login fehlgeschlagen.", "success" if ok else "warning")
except Exception as e:
push_log(f"Maschinen-Login Fehler: {e}", "error")
# Routes
app.include_router(routes.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
def index():
# no-store, damit der Browser nach einem Update immer die neue
# index.html (inkl. aktualisiertem JS) lädt und nicht die alte cacht.
return FileResponse(
os.path.join(STATIC_DIR, "index.html"),
headers={"Cache-Control": "no-store, must-revalidate"},
)
return app
if __name__ == "__main__":
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
handlers=[logging.StreamHandler()],
)
app = create_app()
uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="warning")

View File

@@ -0,0 +1,5 @@
fastapi>=0.111.0
uvicorn>=0.29.0
paramiko>=3.4.0
requests>=2.31.0
python-multipart>=0.0.9