Architektur
Der Überblick im Großen. Das Warum hinter jeder Entscheidung steht in den Entscheidungsdokumenten.
Im Überblick
Abschnitt betitelt „Im Überblick“┌──────────────────────┐ HTTP/JSON ┌──────────────────────────────┐ TCP/9100 ┌──────────┐│ Browser / PWA │ ──────────► │ Hub (ein Python-Prozess) │ ─────────► │ Drucker ││ React + TypeScript │ + SSE │ FastAPI, asyncio │ │ Brother ││ Service Worker │ ◄────────── │ liefert API, /api/v1/events │ ◄───────── │ PT / QL ││ Scannen per Kamera │ │ und die gebaute Oberfläche │ passiver └──────────┘└──────────────────────┘ │ Warteschlange + Zustände │ Status │ SQLite, EventBus │ UDP/161 SNMP └──────────────────────────────┘Ein Container (ADR 0015, löst ADR 0001 ab):
labelyard– Python/FastAPI; spricht mit den Druckern, führt die Warteschlange, bietet die JSON-API, den JSON-Ereignisstrom und die statische Weboberfläche (mitindex.htmlals Rückfall für die Routen im Browser)
Die Weboberfläche wird beim Bau des Images aus frontend/ gebaut (mehrstufiges Dockerfile, Build-Kontext ist die Wurzel des Repositorys) und ins Image kopiert; es gibt keinen eigenen Prozess und kein eigenes Image für das Frontend. Der Reverse Proxy des Betreibers (Traefik / Pangolin / Caddy / ohne Proxy – siehe Reverse Proxy und examples/) zeigt direkt auf den Hub auf Port 8000. Drucker, Integrationen und Schlüssel verwaltest du in der Oberfläche, sie liegen in der Datenbank (ADR 0017); Geheimnisse sind mit LABELYARD_SECRET_KEY verschlüsselt.
Die Bausteine
Abschnitt betitelt „Die Bausteine“backend/app/├── main.py # FastAPI-App und Lebenszyklus├── cli.py # Befehl labelyard (admin create-key, ...)├── config.py # Einstellungen aus der Umgebung├── api/│ ├── routes/ # ein Router je Ressource (print, jobs, printers, sheets, webhooks, events, ...)│ └── dependencies/ # Anmeldung, Ratenlimits, Webhook-Schlüssel├── auth/ # API-Schlüssel, Sitzungen, CSRF├── printer_models/ # ein Plugin je Modellfamilie (ADR 0004)│ ├── base.py # Protokoll PrinterModel│ ├── pt.py # Treiber der Brother-PT-Serie│ ├── ql.py # Treiber der Brother-QL-Serie│ └── registry.py # ModelRegistry: Entry Points, Suche nach PJL-Zeichenkette / SNMP-Wert├── printer_backends/ # wie die Bytes zum Drucker kommen (ADR 0020)│ ├── base.py # Schnittstelle der Backends│ ├── ptouch_backend.py # PT-Serie über TCP/9100│ ├── brother_ql_backend.py # QL-Serie über TCP/9100│ ├── mock_backend.py # Schein-Drucker für Tests und die Smoke-Compose-Datei│ └── ipp/ # eigener IPP-Client für Seitendrucker├── services/│ ├── layout_engine.py # zeichnet ein Etikett aus LabelData und einem Inhaltstyp│ ├── print_service.py # Vorprüfung, Nachschlagen, Zeichnen, Speichern, Einreihen│ ├── print_queue.py # asyncio-Warteschlange + Worker je Drucker (ADR 0005)│ ├── page_job_runner.py # Seitenaufträge auf IPP-Druckern, abgerechnet nach dem, was der Drucker meldet│ ├── printer_runtime.py # der eine Codepfad, der einen Drucker in den laufenden Hub einhängt (ADR 0024)│ ├── hardware_identity.py # Identität eines Druckers aus seiner Seriennummer (ADR 0024)│ ├── status_block.py # liest den 32-Byte-Statusblock von Brother│ ├── status_freshness.py # wie alt ein zwischengespeicherter Status sein darf, bevor er als unbekannt gilt│ ├── roll_tracker.py # erkennt den Wechsel der Rolle (Bandkassette)│ ├── tape_registry.py # Nachschlagetabelle der Brother-Bänder│ ├── event_bus.py # Publish/Subscribe im Speicher für SSE (ADR 0009)│ └── producers/ # speisen den Event-Bus (Warteschlange, Statusabfrage, Bandwechsel, IPP-Status)├── integrations/│ ├── base.py # Protokoll IntegrationPlugin│ ├── registry.py # IntegrationRegistry: Entry Points│ ├── snipeit/plugin.py│ ├── spoolman/plugin.py│ └── grocy/plugin.py├── sheets/ # Bogentypen, Planer, Reservierungen (ADR 0023)├── calibration/ # Kalibrierung der Seitendrucker (Anpassung, Modell, Dienst)├── page_render/ # PDF-Seiten für Seitendrucker├── settings_store/ # Einstellungen in der Datenbank; die Umgebung sperrt Felder (ADR 0017)├── models/ # SQLModel-Persistenz (Aufträge, Drucker, Rollen, Bögen, ...)├── repositories/ # Datenbankzugriff├── schemas/ # Modelle für Anfragen und Antworten├── db/ # Engine, Sitzung, Lebenszyklus└── seed/ # Bogentypen, die beim Start geladen werdenWeboberfläche (ADR 0016, löst 0003 ab)
Abschnitt betitelt „Weboberfläche (ADR 0016, löst 0003 ab)“frontend/├── src/│ ├── app/ # Router, Sitzung, Query-Client, Hinweis auf neue Versionen│ ├── api/ # Client, Ereignisstrom, Typen aus dem OpenAPI-Vertrag erzeugt (ADR 0011)│ ├── features/ # Übersicht, Drucker, Aufträge, Integrationen, Nachschlagen, Scannen, Suche, Einstellungen│ ├── shell/, pages/ # App-Rahmen (Seitenleiste / Tab-Leiste) und Seitenrahmen│ ├── design/ # Design-Tokens (Spec 5.2)│ └── i18n/ # Meldungskataloge (Englisch als Standard)├── public/ # PWA-Symbole, Manifest└── e2e/ # Playwright-Tests gegen das echte Backend (Schein-Drucker)Browser melden sich mit einem API-Schlüssel (oder über SSO am Proxy) an und bekommen ein Sitzungs-Cookie mit CSRF-Schutz (ADR 0019 für die Vertrauensregeln, und Benutzer und SSO).
Datenflüsse
Abschnitt betitelt „Datenflüsse“Pull-Modus – jemand scannt in der Oberfläche einen Barcode
Abschnitt betitelt „Pull-Modus – jemand scannt in der Oberfläche einen Barcode“- Jemand öffnet
https://labelyard.example.com/(ausgeliefert vom Hub) - Öffnet einen Eintrag: sucht ihn in Snipe-IT, Grocy oder Spoolman, scannt seinen Barcode oder folgt einem Etikett-Link (
/asset/<id>) → die Oberfläche ruftGET /api/v1/lookup/snipeit/<id>auf - Das Backend holt die Daten des Assets aus Snipe-IT und gibt
LabelDatazurück - Der Druckdialog zeigt eine Vorschau, die das Backend zeichnet; die Person wählt Drucker, Gestaltung und Anzahl
- Sie klickt Drucken →
POST /api/v1/print→ das Backend reiht den Auftrag ein und antwortet202+job_id - Die Oberfläche hält für den ganzen Tab schon eine SSE-Verbindung zu
/api/v1/events - Der Worker der Warteschlange im Backend nimmt den Auftrag, öffnet TCP/9100 zum Drucker und schickt die Rasterdaten
- Das Backend legt die Ereignisse
jobundstatusauf den EventBus → SSE (JSON-Frames) → die Oberfläche aktualisiert ihre Daten an Ort und Stelle - Der Auftrag ist fertig; sein Zustand auf der Seite ändert sich durch das SSE-Ereignis. Der Hub schickt keine Benachrichtigung im Browser (eine Benachrichtigungszentrale ist in #255 geplant)
Push-Modus – Webhooks von Spoolman und Grocy
Abschnitt betitelt „Push-Modus – Webhooks von Spoolman und Grocy“Der Hub nimmt Webhooks für Spulen aus Spoolman (POST /api/v1/webhook/spoolman) und Produkte aus Grocy (POST /api/v1/webhook/grocy) an. Grocy schickt sie selbst, wenn in seiner data/config.php FEATURE_FLAG_LABEL_PRINTING=true gesetzt ist; bei Spoolman ruft das, was auf das Spulen-Ereignis reagiert (zum Beispiel ein Automatisierungswerkzeug), die Route auf. Snipe-IT hat keine Webhook-Route und arbeitet nur im Pull-Modus über die Oberfläche des Hubs. Erste Schritte und Webhooks nennen die Inhalte.
- Grocy schickt an
https://labelyard.example.com/api/v1/webhook/grocy(ein Ereignis aus Spoolman geht an/api/v1/webhook/spoolman) - Der Reverse Proxy leitet die Webhook-Unterroute ohne SSO an den Hub
- Das Backend prüft
X-API-Keyund das Ratenlimit je Schlüssel (LABELYARD_WEBHOOK_LIMIT_PER_MINUTE, Standard 120) und gibt dann ein Etikettqr_two_linesfür die Spule oder das Produkt über den Druckdienst an den ersten eingeschalteten Banddrucker, auf demselben Weg wiePOST /api/v1/print: Vorprüfung, Nachschlagen, Zeichnen, Speichern, Einreihen. Fehler am Drucker und beim Nachschlagen beantwortet er wie die Druck-Route mit 409/422/502/503, eine unbekannte Spule oder ein unbekanntes Produkt mit 404app-lookup-not-foundund eine nicht eingerichtete Integration mit 503integration_not_configured(beide im Format eines Problem-Details); eingereiht wird dann nichts. Es wird ein Etikett gedruckt;quantitywird angenommen, aber nicht gedruckt (#284) - Die Antwort ist
202mit der Nummer des Auftrags; der Aufrufer wartet nicht auf das Ende des Drucks (Webhook-Aufträge haben keinen Besitzer, deshalb sehen sie nur Admins und SSO-Aufrufer) - Das Backend druckt; jeder offene SSE-Strom sieht die Zustandswechsel live
Status je Phase (ADR 0006)
Abschnitt betitelt „Status je Phase (ADR 0006)“| Frage | Quelle |
|---|---|
| Banddrucker: Status, Bandbreite, Seitenzähler | SNMP, abgefragt von StatusProbeProducer (standardmäßig alle 30 s). ESC i S auf TCP/9100 bekommt bei den Familien PT und QL über das Netz keine Antwort |
| Banddrucker: Hat die Rolle gewechselt? | roll_tracker vergleicht die Abfrage mit dem gespeicherten Datensatz der Rolle; der Drucker meldet keine Bandfarbe, deshalb fragt der Hub beim Rollenwechsel nach |
| Seitendrucker (IPP): Status und Verbrauchsmaterial | IppStatusProducer, abgefragt mit Rückzug nach Fehlschlägen |
| Ist der zwischengespeicherte Status noch gut? | status_freshness: Älter als 90 s oder drei Abfrageintervalle (das Längere zählt) erscheint er als unbekannt |
Warteschlange (ADR 0005)
Abschnitt betitelt „Warteschlange (ADR 0005)“- Eine
asyncio.Queueund eine Worker-Koroutine je Drucker - In SQLite gespeichert (Aufträge überstehen einen Neustart des Hubs)
- Zustände:
queued | paused | printing | completed | failed | cancelled - Abbrechen mitten im Druck geht bei Banddruckern nicht (die Brother-Spezifikation verbietet Befehle während des Drucks). IPP-Seitendrucker können einen laufenden Auftrag abbrechen (
Cancel-Job, ADR 0020) - Seitenaufträge (IPP-Drucker) verfolgt der Hub am Drucker bis zu einem Endzustand und rechnet sie nach einem Neustart nach dem ab, was der Drucker meldet; Bandaufträge in
printingwerden zufailed_restart - Aktionen: Pause/Fortsetzen/Abbrechen/Wiederholen/Priorität bei Aufträgen; Pause/Fortsetzen/Leeren beim Worker eines Druckers
API-Vertrag (ADR 0011)
Abschnitt betitelt „API-Vertrag (ADR 0011)“Die OpenAPI-3.1-Spezifikation des Backends ist maßgeblich:
| Pfad | Für wen |
|---|---|
GET /openapi.json |
Werkzeuge (Codegenerierung, Postman, Vertragstests) |
GET /docs |
Interaktive API-Referenz (Scalar, vom Hub ausgeliefert, ohne CDN) |
Die API-Typen der Weboberfläche werden aus dem OpenAPI-Dokument des Backends erzeugt (pnpm gen:api), und die CI schlägt fehl, wenn der erzeugte Client nicht mehr zum Vertrag passt.
- Conventional Commits bestimmen über semantic-release die Versionssprünge
- Der Release-Workflow läuft jede Nacht um 04:00 UTC oder per manuellem Start auf
main, nicht bei jedem Push. Er veröffentlicht nur, wenn releasefähige Commits vorliegen: Tag und GitHub-Release als Entwurf → Docker-Image auf GHCR + Docker Hub → SBOM als Asset am Entwurf → der Entwurf wird veröffentlicht. Ein veröffentlichtes Release ist unveränderlich, deshalb wird zuletzt veröffentlicht - Die Registry-Anmeldungen im Image-Workflow werden bis zu viermal versucht; ein dauerhafter Fehler lässt den Lauf fehlschlagen
- Scheitern die Images, die SBOM oder das Veröffentlichen, bleibt das Release ein Entwurf. Die Step-Summary des Laufs nennt dann die fehlende Version und die Lauf-ID. Neu gebaut wird mit
gh run rerun <lauf-id> --failed: Das wiederholt die Image-Jobs, die Release-SBOM und das Veröffentlichen aufrefs/heads/main. Die Images werden also signiert, die SBOM wird angehängt, und das Release wird veröffentlicht. Lässt sich der Lauf nicht mehr wiederholen, bautgh workflow run docker-publish.yml --ref main -f tag=<version>den Commit des Tags; die SBOM kommt dann von Hand mitgh release uploadan den Entwurf. Danach veröffentlichtgh release edit v<version> --draft=false --latestihn. Ist schon eine höhere Version veröffentlicht, gilt--latest=false. Achtung: Dieser Start setztlatest,X.YundXauf diese Version; nutze ihn nur für das neueste stabile Release. Nicht auf dem Tag-Ref starten: Images aus einem Tag-Ref werden nicht signiert - Tag-Schema (ADR 0007): Jedes stabile Release veröffentlicht
1.0.0,1.0,1undlatest– Vorabversionen bekommen nur die volle Version - Mehrere Architekturen:
linux/amd64+linux/arm64
SSE-EventBus
Abschnitt betitelt „SSE-EventBus“Das Backend bietet GET /api/v1/events[?printer_id=<uuid>] als Strom von Server-Sent Events mit JSON-Frames. Das Singleton EventBus (auf app.state.event_bus) verteilt die BusEvent-Instanzen der Producer an alle verbundenen Abonnenten:
| Producer | Kanal | Ereignistyp |
|---|---|---|
PrintQueueProducer |
printer:{id}:queue |
job.state_changed |
StatusProbeProducer |
printer:{id}:state |
printer.status |
TapeChangeProducer |
printer:{id}:tape |
printer.tape_changed |
| Admin-API für Drucker | hub |
hub.printers_changed |
Der Strom beginnt mit einem Schnappschuss je sichtbarem Drucker, meldet sich an wie jede read-Route und endet mit auth.expired, sobald die Zugangsdaten nicht mehr gelten. Das Format der Frames und die Flush-Einstellungen für Traefik, Caddy, Nginx und Pangolin: Ereignisstrom und Reverse Proxys. Den früheren Strom mit HTML-Fragmenten /api/events (für HTMX) gibt es nicht mehr.
Was der Reverse Proxy leisten muss
Abschnitt betitelt „Was der Reverse Proxy leisten muss“Der Hub lauscht in seinem Container auf Port 8000 und liefert dort Oberfläche und API aus. SSE verlangt, dass der Reverse Proxy Antworten nicht puffert (siehe Ereignisstrom und Reverse Proxys für Traefik/Caddy/Nginx/Pangolin und examples/ für vollständige Compose-Beispiele).
Für Pangolin gilt das Muster mit zwei Ressourcen:
- Hauptoberfläche: durch SSO geschützt, ganze Domain; die Ressource setzt den Header mit dem Vertrauens-Token, den der Hub verlangt (
LABELYARD_SSO_TRUST_TOKEN) - Webhook-Unterroute (
/api/v1/webhook/): ohne SSO, geschützt durchLABELYARD_WEBHOOK_API_KEY
Wo was liegt
Abschnitt betitelt „Wo was liegt“| Du suchst | Ort |
|---|---|
| Warum etwas so entschieden wurde | Entscheidungsdokumente |
| Wie man den Hub betreibt | Erste Schritte |
| Wie man ein Druckermodell oder ein Nachschlage-Plugin ergänzt | Plugins schreiben |
| API-Referenz | API-Referenz, und /docs auf einem laufenden Hub |
| Recherche-Notizen (Druckerprotokolle, IPP-Kompatibilität) | Recherche-Notizen |
| Datenschutz und Marken | Richtlinien |
| Beispiel-Installationen | examples/ |