diff --git a/BACKEND_API.md b/BACKEND_API.md index fa48065..fe7b208 100644 --- a/BACKEND_API.md +++ b/BACKEND_API.md @@ -1,40 +1,89 @@ -# Backend API für LabelPrintAgent +# Backend API – LabelPrintAgent -Diese Datei beschreibt die Endpunkte, die der PaperlessManager bereitstellen muss, damit der LabelPrintAgent Etiketten abholen, drucken und das Ergebnis zurückmelden kann. +Diese Datei beschreibt die Endpunkte des PaperlessManager-Backends für den LabelPrintAgent. -Der LabelPrintAgent rendert keine Layouts selbst. Das Backend liefert ein fertiges Etikettbild. +Das Backend rendert das fertige Etikettbild (SVG -> PNG via resvg-js). Der Agent ist nur lokaler Druck-Connector. ## Authentifizierung -Alle Endpunkte sollten denselben Bearer Token akzeptieren: +Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key): ```http -Authorization: Bearer {apiToken} +Authorization: Bearer {token} ``` -Der Token wird im Agent lokal verschlüsselt gespeichert. +`/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen keine spezifische Permission, nur einen gültigen Token. `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`. -## 1. Nächsten Druckjob abrufen +## 1. Job manuell anlegen (Frontend -> Backend) + +```http +POST /api/label-print-agent/jobs +Content-Type: application/json +``` + +### Request Body + +```json +{ + "templateId": 3, + "fieldValues": { + "datum": "2026-05-09" + } +} +``` + +| Feld | Pflicht | Beschreibung | +| --- | --- | --- | +| `templateId` | ja | ID des BarcodeTemplates | +| `fieldValues` | nein | Feldwerte für Platzhalter; Datumsfelder im Format `YYYY-MM-DD` | + +### Antwort + +```http +201 Created +``` + +```json +{ "jobId": "42" } +``` + +## 2. Vorschau-Bild rendern (kein Job, keine Nummer-Reservierung) + +```http +POST /api/label-print-agent/preview +Content-Type: application/json +``` + +### Request Body + +Identisch mit `POST /jobs`. `{number}` wird immer als `1` gerendert; die GET-URL wird nicht aufgerufen. + +### Antwort + +```http +200 OK +Content-Type: image/png +``` + +Body: binäres PNG-Bild. + +## 3. Nächsten Druckjob abrufen (Agent-Polling) ```http GET /api/label-print-agent/jobs/next?agentId={agentId} ``` -Der Agent ruft diesen Endpunkt alle X Sekunden auf. - -### Query-Parameter - -| Name | Pflicht | Beschreibung | +| Parameter | Pflicht | Beschreibung | | --- | --- | --- | -| `agentId` | ja | Eindeutige ID des Agents, z. B. Rechnername | +| `agentId` | nein | Eindeutige Agent-ID, z. B. Rechnername; Fallback: `unknown` | -### Antwort, wenn kein Job vorhanden ist +### Antwort – kein Job vorhanden ```http 204 No Content ``` -### Antwort mit Bild direkt im JSON +### Antwort – Job vorhanden ```http 200 OK @@ -43,7 +92,7 @@ Content-Type: application/json ```json { - "jobId": "12345", + "jobId": "42", "labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...", "labelImageContentType": "image/png", "labelWidthMm": 57, @@ -51,34 +100,18 @@ Content-Type: application/json } ``` -### Antwort mit separater Bild-URL +| Feld | Beschreibung | +| --- | --- | +| `jobId` | Job-ID für Rückmeldungen | +| `labelImageBase64` | Base64-PNG; `null` wenn Rendering fehlgeschlagen | +| `labelImageContentType` | Immer `image/png` | +| `labelWidthMm` / `labelHeightMm` | Etikettenmaß in mm | -```json -{ - "jobId": "12345", - "labelImageUrl": "/api/label-print-agent/jobs/12345/image", - "labelImageContentType": "image/png", - "labelWidthMm": 57, - "labelHeightMm": 32 -} -``` +Das Backend setzt beim Ausliefern einen Lock (5-Minuten-TTL). Jobs mit abgelaufenem Lock werden erneut angeboten. -### Felder +## 4. Etikettbild separat abrufen -| Feld | Pflicht | Beschreibung | -| --- | --- | --- | -| `jobId` | ja | Eindeutige Job-ID für Rückmeldungen | -| `labelImageBase64` | bedingt | Base64-kodiertes Etikettbild | -| `labelImageUrl` | bedingt | URL zum Nachladen des Etikettbilds | -| `labelImageContentType` | empfohlen | z. B. `image/png` | -| `labelWidthMm` | ja | Etikettenbreite in mm, z. B. `57` | -| `labelHeightMm` | ja | Etikettenhöhe in mm, z. B. `32` | - -`labelImageBase64` oder `labelImageUrl` muss gesetzt sein. - -## 2. Etikettbild nachladen - -Nur erforderlich, wenn `labelImageUrl` verwendet wird. +Alternativ zum Base64-Feld in `jobs/next`. ```http GET /api/label-print-agent/jobs/{jobId}/image @@ -91,11 +124,15 @@ GET /api/label-print-agent/jobs/{jobId}/image Content-Type: image/png ``` -Body: Binärdaten des fertigen Etikettbilds. +Body: binäres PNG-Bild. -Empfehlung: PNG, schwarz/weiß, passend zum Etikettenformat, z. B. 57 x 32 mm bei 300 dpi. +```http +404 Not Found +``` -## 3. Erfolgreichen Druck melden +Job oder Bild nicht vorhanden. + +## 5. Erfolgreichen Druck melden ```http POST /api/label-print-agent/jobs/{jobId}/printed @@ -111,21 +148,21 @@ Content-Type: application/json } ``` +Alle Felder optional; Fallback jeweils `""` / `unknown`. + ### Antwort ```http 200 OK ``` -oder: - -```http -204 No Content +```json +{ "ok": true } ``` -Das Backend sollte den Job erst hier endgültig als gedruckt markieren. +Das Backend setzt den Job auf `printed`, speichert Zeitstempel und ruft die konfigurierte `LabelPrintedUrl` des Templates auf (`POST`). -## 4. Fehler melden +## 6. Druckfehler melden ```http POST /api/label-print-agent/jobs/{jobId}/error @@ -138,7 +175,7 @@ Content-Type: application/json { "agentId": "PC-BUERO", "printerName": "DYMO LabelWriter 450", - "errorMessage": "Drucker ist nicht verfügbar." + "errorMessage": "Drucker nicht verfügbar." } ``` @@ -148,63 +185,53 @@ Content-Type: application/json 200 OK ``` -oder: - -```http -204 No Content +```json +{ "ok": true } ``` -Das Backend entscheidet danach, ob der Job erneut angeboten wird oder auf Fehler bleibt. +Das Backend setzt den Job auf `error` und ruft die konfigurierte `LabelReleaseUrl` des Templates auf (`POST`). -## Backend-Verhalten +## Job-Lebenszyklus -Empfohlener Ablauf im Backend: +```text +createJob() + | + v + pending ---- jobs/next ---- Lock (5 Min TTL) + | + Agent druckt + | + +-------+-------+ + printed error + (PrintedUrl) (ReleaseUrl) +``` -1. Job erstellen und serverseitig Layout, Nummern, QR-Code und Bild erzeugen. -2. Job bleibt wartend, bis ein Agent ihn abholt. -3. `jobs/next` liefert jeweils höchstens einen Job. -4. Backend reserviert oder lockt den Job beim Ausliefern, damit zwei Agents ihn nicht parallel drucken. -5. Agent druckt lokal. -6. Agent meldet `printed` oder `error`. -7. Backend setzt den finalen Status. +## 7. Server-Sent Events – neue Druckaufträge (Push) -## Empfohlene Statuscodes +```http +GET /api/label-print-agent/events +Authorization: Bearer {token} +Accept: text/event-stream +``` + +Der Agent verbindet sich einmalig. Sobald ein neuer Druckauftrag erstellt wird, sendet das Backend: + +```text +data: {"type":"label-job-available"} +``` + +Der Agent ruft daraufhin sofort `GET /jobs/next` auf. Polling bleibt als Fallback sinnvoll, z. B. alle 30 Sekunden, falls die SSE-Verbindung unterbrochen wurde. + +Es werden keine `agentId`-Parameter ausgewertet; alle verbundenen Agents erhalten das Event. + +## Statuscodes | Situation | Status | | --- | --- | | Kein Job vorhanden | `204 No Content` | -| Job vorhanden | `200 OK` | -| Token fehlt/ungültig | `401 Unauthorized` | -| Agent darf nicht drucken | `403 Forbidden` | +| Job / Bild vorhanden | `200 OK` | +| Job erstellt | `201 Created` | +| Token fehlt / ungültig | `401 Unauthorized` | +| Fehlende Permission | `403 Forbidden` | | Job-ID unbekannt | `404 Not Found` | | Backend-Fehler | `500 Internal Server Error` | - -## Server-Sent Events optional - -Später kann das Backend zusätzlich einen Event-Endpunkt anbieten: - -```http -GET /api/label-print-agent/events?agentId={agentId} -Accept: text/event-stream -``` - -Beispiel: - -```text -event: label-job-available -data: {"count":1} -``` - -Der Agent könnte dann bei einem Event sofort `jobs/next` aufrufen. Polling bleibt trotzdem als Fallback sinnvoll. - -## Wichtige Designentscheidung - -Der Agent kennt keine fachlichen Layouts mehr: - -- keine `layout_key` -- keine lokalen LabelTemplates -- keine MySQL-Verbindung -- keine Nummernreservierung -- kein QR-Code-Rendering - -Das Backend liefert ein fertiges Bild. Der Agent ist nur noch lokaler Windows-Druck-Connector. diff --git a/README.md b/README.md index b3fee13..1d83840 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ LabelPrintAgent -> meldet Erfolg oder Fehler ans Backend zurück ``` -Optional kann später statt Polling Server-Sent Events ergänzt werden. Der aktuelle Stand nutzt bewusst Polling, weil es robuster und einfacher zu betreiben ist. +Der Agent unterstützt Server-Sent Events für Push-Benachrichtigungen und nutzt Polling als Fallback. ## Backend-Vertrag @@ -102,8 +102,11 @@ Beispiel: "agentId": "PC-BUERO", "encryptedApiToken": "", "nextJobPath": "/api/label-print-agent/jobs/next", + "imagePath": "/api/label-print-agent/jobs/{jobId}/image", "reportSuccessPath": "/api/label-print-agent/jobs/{jobId}/printed", - "reportErrorPath": "/api/label-print-agent/jobs/{jobId}/error" + "reportErrorPath": "/api/label-print-agent/jobs/{jobId}/error", + "useServerSentEvents": true, + "eventsPath": "/api/label-print-agent/events" }, "printer": { "printerName": "DYMO LabelWriter 450", @@ -113,7 +116,7 @@ Beispiel: }, "worker": { "enabled": true, - "pollIntervalSeconds": 5 + "pollIntervalSeconds": 30 } } ``` diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index 35bde18..8833f35 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -24,8 +24,11 @@ internal sealed class SettingsForm : Form private readonly TextBox _agentId = new() { Width = 260 }; private readonly TextBox _apiToken = new() { Width = 420, UseSystemPasswordChar = true }; private readonly TextBox _nextJobPath = new() { Width = 420 }; + private readonly TextBox _imagePath = new() { Width = 420 }; private readonly TextBox _reportSuccessPath = new() { Width = 420 }; private readonly TextBox _reportErrorPath = new() { Width = 420 }; + private readonly CheckBox _useServerSentEvents = new() { Text = "Server-Sent Events verwenden", AutoSize = true }; + private readonly TextBox _eventsPath = new() { Width = 420 }; private readonly ComboBox _printer = new() { Width = 360, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly NumericUpDown _labelWidth = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 57, Width = 100 }; @@ -82,10 +85,13 @@ internal sealed class SettingsForm : Form panel.Controls.Add(Row(60, Label("AgentId", 160), _agentId)); panel.Controls.Add(Row(100, Label("API Token", 160), _apiToken)); panel.Controls.Add(Row(140, Label("NextJobPath", 160), _nextJobPath)); - panel.Controls.Add(Row(180, Label("ReportSuccessPath", 160), _reportSuccessPath)); - panel.Controls.Add(Row(220, Label("ReportErrorPath", 160), _reportErrorPath)); - panel.Controls.Add(ButtonAt("Speichern", 180, 275, (_, _) => SaveBackend(showMessage: true))); - panel.Controls.Add(ButtonAt("Jetzt prüfen", 300, 275, async (_, _) => await PollOnceFromUiAsync())); + panel.Controls.Add(Row(180, Label("ImagePath", 160), _imagePath)); + panel.Controls.Add(Row(220, Label("ReportSuccessPath", 160), _reportSuccessPath)); + panel.Controls.Add(Row(260, Label("ReportErrorPath", 160), _reportErrorPath)); + panel.Controls.Add(Row(300, Label("SSE", 160), _useServerSentEvents)); + panel.Controls.Add(Row(340, Label("EventsPath", 160), _eventsPath)); + panel.Controls.Add(ButtonAt("Speichern", 180, 395, (_, _) => SaveBackend(showMessage: true))); + panel.Controls.Add(ButtonAt("Jetzt prüfen", 300, 395, async (_, _) => await PollOnceFromUiAsync())); return new TabPage("Backend") { Controls = { panel } }; } @@ -119,8 +125,11 @@ internal sealed class SettingsForm : Form _agentId.Text = settings.Backend.AgentId; _apiToken.Text = _settingsStore.DecryptBackendApiToken(settings); _nextJobPath.Text = settings.Backend.NextJobPath; + _imagePath.Text = settings.Backend.ImagePath; _reportSuccessPath.Text = settings.Backend.ReportSuccessPath; _reportErrorPath.Text = settings.Backend.ReportErrorPath; + _useServerSentEvents.Checked = settings.Backend.UseServerSentEvents; + _eventsPath.Text = settings.Backend.EventsPath; _labelWidth.Value = settings.Printer.LabelWidthMm; _labelHeight.Value = settings.Printer.LabelHeightMm; @@ -161,8 +170,11 @@ internal sealed class SettingsForm : Form settings.Backend.AgentId = _agentId.Text.Trim(); settings.Backend.EncryptedApiToken = _settingsStore.EncryptPassword(_apiToken.Text); settings.Backend.NextJobPath = _nextJobPath.Text.Trim(); + settings.Backend.ImagePath = _imagePath.Text.Trim(); settings.Backend.ReportSuccessPath = _reportSuccessPath.Text.Trim(); settings.Backend.ReportErrorPath = _reportErrorPath.Text.Trim(); + settings.Backend.UseServerSentEvents = _useServerSentEvents.Checked; + settings.Backend.EventsPath = _eventsPath.Text.Trim(); _settingsStore.Save(settings); AppendStatus("Backend-Einstellungen gespeichert."); if (showMessage) diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs index e7bec17..c97b477 100644 --- a/src/LabelPrintAgent/App/TrayApplicationContext.cs +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -108,11 +108,6 @@ internal sealed class TrayApplicationContext : ApplicationContext return (false, "Backend-URL fehlt."); } - if (string.IsNullOrWhiteSpace(settings.Backend.AgentId)) - { - return (false, "Agent-ID fehlt."); - } - if (string.IsNullOrWhiteSpace(settings.Printer.PrinterName)) { return (false, "Drucker fehlt."); diff --git a/src/LabelPrintAgent/Backend/BackendClient.cs b/src/LabelPrintAgent/Backend/BackendClient.cs index 2b49264..4a3db8b 100644 --- a/src/LabelPrintAgent/Backend/BackendClient.cs +++ b/src/LabelPrintAgent/Backend/BackendClient.cs @@ -1,6 +1,7 @@ using System.Drawing; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using LabelPrintAgent.Configuration; namespace LabelPrintAgent.Backend; @@ -41,12 +42,15 @@ public sealed class BackendClient if (string.IsNullOrWhiteSpace(job.LabelImageUrl)) { - throw new InvalidOperationException("Backend-Job enthält weder labelImageBase64 noch labelImageUrl."); + var settingsForPath = _settingsStore.Load(); + job.LabelImageUrl = settingsForPath.Backend.ImagePath.Replace("{jobId}", Uri.EscapeDataString(job.JobId), StringComparison.OrdinalIgnoreCase); } var settings = _settingsStore.Load(); using var request = CreateRequest(HttpMethod.Get, MakeAbsoluteUrl(settings.Backend.BaseUrl, job.LabelImageUrl), settings); - var bytesFromUrl = await (await _httpClient.SendAsync(request, cancellationToken)).Content.ReadAsByteArrayAsync(cancellationToken); + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + var bytesFromUrl = await response.Content.ReadAsByteArrayAsync(cancellationToken); return CreateBitmap(bytesFromUrl); } @@ -82,6 +86,33 @@ public sealed class BackendClient response.EnsureSuccessStatusCode(); } + public async Task WatchServerSentEventsAsync(Func onLabelJobAvailable, CancellationToken cancellationToken = default) + { + var settings = _settingsStore.Load(); + using var request = CreateRequest(HttpMethod.Get, MakeAbsoluteUrl(settings.Backend.BaseUrl, settings.Backend.EventsPath), settings); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null || !line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var data = line["data:".Length..].Trim(); + if (IsLabelJobAvailableEvent(data)) + { + await onLabelJobAvailable(cancellationToken); + } + } + } + private HttpRequestMessage CreateRequest(HttpMethod method, string url, AppSettings settings) { var request = new HttpRequestMessage(method, url); @@ -97,7 +128,8 @@ public sealed class BackendClient private static string BuildUrl(string baseUrl, string path, string agentId) { var separator = path.Contains('?') ? '&' : '?'; - return $"{MakeAbsoluteUrl(baseUrl, path)}{separator}agentId={Uri.EscapeDataString(agentId)}"; + var effectiveAgentId = string.IsNullOrWhiteSpace(agentId) ? "unknown" : agentId; + return $"{MakeAbsoluteUrl(baseUrl, path)}{separator}agentId={Uri.EscapeDataString(effectiveAgentId)}"; } private static string MakeAbsoluteUrl(string baseUrl, string pathOrUrl) @@ -117,4 +149,23 @@ public sealed class BackendClient using var stream = new MemoryStream(bytes); return new Bitmap(stream); } + + private static bool IsLabelJobAvailableEvent(string data) + { + if (string.IsNullOrWhiteSpace(data)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(data); + return document.RootElement.TryGetProperty("type", out var type) + && string.Equals(type.GetString(), "label-job-available", StringComparison.OrdinalIgnoreCase); + } + catch (JsonException) + { + return data.Contains("label-job-available", StringComparison.OrdinalIgnoreCase); + } + } } diff --git a/src/LabelPrintAgent/Backend/BackendPollingWorker.cs b/src/LabelPrintAgent/Backend/BackendPollingWorker.cs index 137f23e..2240547 100644 --- a/src/LabelPrintAgent/Backend/BackendPollingWorker.cs +++ b/src/LabelPrintAgent/Backend/BackendPollingWorker.cs @@ -12,6 +12,7 @@ public sealed class BackendPollingWorker : IDisposable private readonly SemaphoreSlim _semaphore = new(1, 1); private CancellationTokenSource? _cancellationTokenSource; private Task? _task; + private Task? _sseTask; private AgentHealthStatus _lastStatus = new(false, "Noch nicht mit dem Backend verbunden."); public BackendPollingWorker(SettingsStore settingsStore, BackendClient backendClient, PrinterService printerService) @@ -34,6 +35,7 @@ public sealed class BackendPollingWorker : IDisposable _cancellationTokenSource = new CancellationTokenSource(); _task = Task.Run(() => RunAsync(_cancellationTokenSource.Token)); + _sseTask = Task.Run(() => RunServerSentEventsAsync(_cancellationTokenSource.Token)); } public async Task PollOnceAsync(CancellationToken cancellationToken = default) @@ -122,12 +124,46 @@ public sealed class BackendPollingWorker : IDisposable } } + private async Task RunServerSentEventsAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var settings = _settingsStore.Load(); + if (!settings.Worker.Enabled || !settings.Backend.UseServerSentEvents || string.IsNullOrWhiteSpace(settings.Backend.BaseUrl)) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + continue; + } + + try + { + SetStatus(LastStatus.IsHealthy, "SSE-Verbindung wird aufgebaut."); + await _backendClient.WatchServerSentEventsAsync(async token => + { + SetStatus(true, "SSE: neuer Druckjob gemeldet."); + await PollOnceAsync(token); + }, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Log.Warning(ex, "Server-sent events connection failed"); + SetStatus(false, $"SSE getrennt: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + } + } + } + public void Dispose() { _cancellationTokenSource?.Cancel(); try { _task?.Wait(TimeSpan.FromSeconds(2)); + _sseTask?.Wait(TimeSpan.FromSeconds(2)); } catch { diff --git a/src/LabelPrintAgent/Configuration/AppSettings.cs b/src/LabelPrintAgent/Configuration/AppSettings.cs index 53cc089..865fb4f 100644 --- a/src/LabelPrintAgent/Configuration/AppSettings.cs +++ b/src/LabelPrintAgent/Configuration/AppSettings.cs @@ -20,8 +20,11 @@ public sealed class BackendSettings public string AgentId { get; set; } = Environment.MachineName; public string EncryptedApiToken { get; set; } = string.Empty; public string NextJobPath { get; set; } = "/api/label-print-agent/jobs/next"; + public string ImagePath { get; set; } = "/api/label-print-agent/jobs/{jobId}/image"; public string ReportSuccessPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/printed"; public string ReportErrorPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/error"; + public bool UseServerSentEvents { get; set; } = true; + public string EventsPath { get; set; } = "/api/label-print-agent/events"; } public sealed class PrinterSettings @@ -35,7 +38,7 @@ public sealed class PrinterSettings public sealed class WorkerSettings { public bool Enabled { get; set; } = true; - public int PollIntervalSeconds { get; set; } = 5; + public int PollIntervalSeconds { get; set; } = 30; } public sealed class PathSettings