diff --git a/BACKEND_API.md b/BACKEND_API.md index a7edb75..0157b57 100644 --- a/BACKEND_API.md +++ b/BACKEND_API.md @@ -12,7 +12,7 @@ Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key): Authorization: Bearer {token} ``` -`/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`. +`/printers/register`, `/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. Job manuell anlegen (Frontend -> Backend) @@ -67,7 +67,38 @@ Content-Type: image/png Body: binäres PNG-Bild. -## 3. Nächsten Druckjob abrufen (Agent-Polling) +## 3. Drucker registrieren (Agent-Start) + +Der Agent ruft diesen Endpunkt beim Start für jeden konfigurierten lokalen Windows-Drucker auf. + +```http +POST /api/label-print-agent/printers/register +Content-Type: application/json +``` + +```json +{ + "printerId": "PC-BUERO_ZEBRA_GK420D", + "agentId": "PC-BUERO", + "name": "Zebra GK420 Büro", + "windowsPrinterName": "Zebra GK420d", + "dpi": 203, + "defaultWidthMm": 101, + "defaultHeightMm": 76 +} +``` + +### Antwort + +```http +200 OK +``` + +```json +{ "ok": true } +``` + +## 4. Nächsten Druckjob abrufen (Agent-Polling) ```http GET /api/label-print-agent/jobs/next?agentId={agentId} @@ -92,7 +123,10 @@ Content-Type: application/json ```json { - "jobId": "42", + "jobId": 42, + "printerId": "PC-BUERO_ZEBRA_GK420D", + "labelType": "artikel", + "windowsPrinterName": "Zebra GK420d", "labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...", "labelImageContentType": "image/png", "labelWidthMm": 57, @@ -103,13 +137,16 @@ Content-Type: application/json | Feld | Beschreibung | | --- | --- | | `jobId` | Job-ID für Rückmeldungen | +| `printerId` | Backend-ID des Zieldruckers | +| `labelType` | Etikettart, z. B. `artikel` oder `text` | +| `windowsPrinterName` | Exakter lokaler Windows-Druckername, auf den der Agent drucken soll | | `labelImageBase64` | Base64-PNG; `null` wenn Rendering fehlgeschlagen | | `labelImageContentType` | Immer `image/png` | | `labelWidthMm` / `labelHeightMm` | Etikettenmaß in mm | Das Backend setzt beim Ausliefern einen Lock (5-Minuten-TTL). Jobs mit abgelaufenem Lock werden erneut angeboten. -## 4. Etikettbild separat abrufen +## 5. Etikettbild separat abrufen Alternativ zum Base64-Feld in `jobs/next`. @@ -132,7 +169,7 @@ Body: binäres PNG-Bild. Job oder Bild nicht vorhanden. -## 5. Erfolgreichen Druck melden +## 6. Erfolgreichen Druck melden ```http POST /api/label-print-agent/jobs/{jobId}/printed @@ -162,7 +199,7 @@ Alle Felder optional; Fallback jeweils `""` / `unknown`. Das Backend setzt den Job auf `printed`, speichert Zeitstempel und ruft die konfigurierte `LabelPrintedUrl` des Templates auf (`POST`). -## 6. Druckfehler melden +## 7. Druckfehler melden ```http POST /api/label-print-agent/jobs/{jobId}/error @@ -206,7 +243,7 @@ createJob() (PrintedUrl) (ReleaseUrl) ``` -## 7. Server-Sent Events – neue Druckaufträge (Push) +## 8. Server-Sent Events – neue Druckaufträge (Push) ```http GET /api/label-print-agent/events diff --git a/README.md b/README.md index 4d4e426..b8eaffc 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,10 @@ Wenn ein Etikett vorhanden ist: ```json { - "jobId": "12345", + "jobId": 12345, + "printerId": "PC-BUERO_DYMO_LABELWRITER_450", + "labelType": "artikel", + "windowsPrinterName": "DYMO LabelWriter 450", "labelImageBase64": "...", "labelImageContentType": "image/png", "labelWidthMm": 57, @@ -50,7 +53,10 @@ Alternativ darf das Backend statt `labelImageBase64` eine URL liefern: ```json { - "jobId": "12345", + "jobId": 12345, + "printerId": "PC-BUERO_DYMO_LABELWRITER_450", + "labelType": "artikel", + "windowsPrinterName": "DYMO LabelWriter 450", "labelImageUrl": "/api/label-print-agent/jobs/12345/image", "labelImageContentType": "image/png", "labelWidthMm": 57, @@ -101,6 +107,7 @@ Beispiel: "baseUrl": "https://paperlessmanager.local", "agentId": "PC-BUERO", "encryptedApiToken": "", + "registerPrinterPath": "/api/label-print-agent/printers/register", "nextJobPath": "/api/label-print-agent/jobs/next", "imagePath": "/api/label-print-agent/jobs/{jobId}/image", "reportSuccessPath": "/api/label-print-agent/jobs/{jobId}/printed", @@ -114,6 +121,24 @@ Beispiel: "labelHeightMm": 32, "dpi": 300 }, + "printers": [ + { + "printerId": "PC-BUERO_DYMO_LABELWRITER_450", + "name": "DYMO LabelWriter Regal", + "windowsPrinterName": "DYMO LabelWriter 450", + "dpi": 300, + "defaultWidthMm": 57, + "defaultHeightMm": 32 + }, + { + "printerId": "PC-BUERO_ZEBRA_GK420D", + "name": "Zebra GK420 Büro", + "windowsPrinterName": "Zebra GK420d", + "dpi": 203, + "defaultWidthMm": 101, + "defaultHeightMm": 76 + } + ], "worker": { "enabled": true, "pollIntervalSeconds": 30 @@ -123,6 +148,8 @@ Beispiel: Der API-Token wird lokal mit Windows DPAPI verschlüsselt gespeichert. +Beim Start registriert der Agent jeden Eintrag aus `printers` beim Backend. Das Feld `printer` bleibt als Kompatibilitätsfeld für ältere Einstellungen erhalten; neue Jobs werden anhand von `windowsPrinterName` aus der Backend-Antwort auf den passenden lokalen Windows-Drucker gedruckt. + ## Dymo-Druck Der Dymo LabelWriter muss in Windows als normaler Drucker eingerichtet sein. Das Backend liefert ein fertiges Bild für das Etikett, typischerweise PNG in `57 x 32 mm`. diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index 8238596..08f510e 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -171,13 +171,15 @@ internal sealed class SettingsForm : Form _installUpdateButton.Enabled = false; _lastUpdateCheck = null; - _labelWidth.Value = settings.Printer.LabelWidthMm; - _labelHeight.Value = settings.Printer.LabelHeightMm; + var primaryPrinter = GetPrimaryPrinter(settings); + _labelWidth.Value = primaryPrinter?.DefaultWidthMm ?? settings.Printer.LabelWidthMm; + _labelHeight.Value = primaryPrinter?.DefaultHeightMm ?? settings.Printer.LabelHeightMm; } private void LoadPrinters() { - var selected = _settingsStore.Load().Printer.PrinterName; + var settings = _settingsStore.Load(); + var selected = GetPrimaryPrinter(settings)?.WindowsPrinterName ?? settings.Printer.PrinterName; _printer.Items.Clear(); var printers = _printerService.GetInstalledPrinters(); foreach (var printer in printers) @@ -228,9 +230,29 @@ internal sealed class SettingsForm : Form private void SavePrinter(bool showMessage) { var settings = _settingsStore.Load(); - settings.Printer.PrinterName = GetSelectedPrinterName() ?? string.Empty; + var selectedPrinterName = GetSelectedPrinterName() ?? string.Empty; + settings.Printer.PrinterName = selectedPrinterName; settings.Printer.LabelWidthMm = _labelWidth.Value; settings.Printer.LabelHeightMm = _labelHeight.Value; + if (!string.IsNullOrWhiteSpace(selectedPrinterName)) + { + var existingPrinter = settings.Printers.FirstOrDefault( + printer => string.Equals(printer.WindowsPrinterName, selectedPrinterName, StringComparison.OrdinalIgnoreCase)); + var printerConfig = existingPrinter ?? new PrinterConfig(); + printerConfig.PrinterId = string.IsNullOrWhiteSpace(printerConfig.PrinterId) + ? SettingsStore.BuildPrinterId(settings.Backend.AgentId, selectedPrinterName) + : printerConfig.PrinterId; + printerConfig.Name = string.IsNullOrWhiteSpace(printerConfig.Name) ? selectedPrinterName : printerConfig.Name; + printerConfig.WindowsPrinterName = selectedPrinterName; + printerConfig.Dpi = settings.Printer.Dpi; + printerConfig.DefaultWidthMm = Math.Max(1, (int)Math.Round(_labelWidth.Value)); + printerConfig.DefaultHeightMm = Math.Max(1, (int)Math.Round(_labelHeight.Value)); + if (existingPrinter is null) + { + settings.Printers.Add(printerConfig); + } + } + _settingsStore.Save(settings); AppendStatus("Druckereinstellungen gespeichert."); if (showMessage) @@ -357,6 +379,11 @@ internal sealed class SettingsForm : Form }; } + private static PrinterConfig? GetPrimaryPrinter(AppSettings settings) + { + return settings.Printers.FirstOrDefault(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName)); + } + private void AppendStatus(string message) { _status.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}"); diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs index 8c6a904..656be63 100644 --- a/src/LabelPrintAgent/App/TrayApplicationContext.cs +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -112,12 +112,15 @@ internal sealed class TrayApplicationContext : ApplicationContext return (false, "Backend-URL fehlt."); } - if (string.IsNullOrWhiteSpace(settings.Printer.PrinterName)) + var configuredPrinters = settings.Printers + .Where(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName)) + .ToList(); + if (configuredPrinters.Count == 0) { return (false, "Drucker fehlt."); } - if (!_printerService.IsPrinterAvailable(settings.Printer.PrinterName)) + if (!configuredPrinters.Any(printer => _printerService.IsPrinterAvailable(printer.WindowsPrinterName))) { return (false, "Drucker nicht verfügbar."); } diff --git a/src/LabelPrintAgent/Backend/BackendClient.cs b/src/LabelPrintAgent/Backend/BackendClient.cs index 707fa8f..2627231 100644 --- a/src/LabelPrintAgent/Backend/BackendClient.cs +++ b/src/LabelPrintAgent/Backend/BackendClient.cs @@ -35,6 +35,37 @@ public sealed class BackendClient return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); } + public async Task RegisterPrintersAsync(CancellationToken cancellationToken = default) + { + var settings = _settingsStore.Load(); + foreach (var printer in settings.Printers.Where(IsRegisterablePrinter)) + { + var body = new + { + printerId = printer.PrinterId, + agentId = settings.Backend.AgentId, + name = printer.Name, + windowsPrinterName = printer.WindowsPrinterName, + dpi = printer.Dpi, + defaultWidthMm = printer.DefaultWidthMm, + defaultHeightMm = printer.DefaultHeightMm + }; + + using var request = CreateRequest( + HttpMethod.Post, + MakeAbsoluteUrl(settings.Backend.BaseUrl, settings.Backend.RegisterPrinterPath), + settings); + request.Content = JsonContent.Create(body); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessWithDiagnosticsAsync(response, request, cancellationToken); + Log.Information( + "Registered printer {PrinterId} ({WindowsPrinterName}) for agent {AgentId}", + printer.PrinterId, + printer.WindowsPrinterName, + settings.Backend.AgentId); + } + } + public async Task GetLabelImageAsync(BackendLabelJob job, CancellationToken cancellationToken = default) { if (!string.IsNullOrWhiteSpace(job.LabelImageBase64)) @@ -46,7 +77,7 @@ public sealed class BackendClient if (string.IsNullOrWhiteSpace(job.LabelImageUrl)) { var settingsForPath = _settingsStore.Load(); - job.LabelImageUrl = settingsForPath.Backend.ImagePath.Replace("{jobId}", Uri.EscapeDataString(job.JobId), StringComparison.OrdinalIgnoreCase); + job.LabelImageUrl = settingsForPath.Backend.ImagePath.Replace("{jobId}", Uri.EscapeDataString(job.JobId.ToString()), StringComparison.OrdinalIgnoreCase); } var settings = _settingsStore.Load(); @@ -57,7 +88,7 @@ public sealed class BackendClient return CreateBitmap(bytesFromUrl); } - public Task ReportPrintedAsync(string jobId, string printerName, CancellationToken cancellationToken = default) + public Task ReportPrintedAsync(int jobId, string printerName, CancellationToken cancellationToken = default) { var settings = _settingsStore.Load(); var request = new BackendReportRequest @@ -68,7 +99,7 @@ public sealed class BackendClient return PostReportAsync(settings, settings.Backend.ReportSuccessPath, jobId, request, cancellationToken); } - public Task ReportErrorAsync(string jobId, string printerName, string errorMessage, CancellationToken cancellationToken = default) + public Task ReportErrorAsync(int jobId, string printerName, string errorMessage, CancellationToken cancellationToken = default) { var settings = _settingsStore.Load(); var request = new BackendReportRequest @@ -80,9 +111,9 @@ public sealed class BackendClient return PostReportAsync(settings, settings.Backend.ReportErrorPath, jobId, request, cancellationToken); } - private async Task PostReportAsync(AppSettings settings, string pathTemplate, string jobId, BackendReportRequest body, CancellationToken cancellationToken) + private async Task PostReportAsync(AppSettings settings, string pathTemplate, int jobId, BackendReportRequest body, CancellationToken cancellationToken) { - var path = pathTemplate.Replace("{jobId}", Uri.EscapeDataString(jobId), StringComparison.OrdinalIgnoreCase); + var path = pathTemplate.Replace("{jobId}", Uri.EscapeDataString(jobId.ToString()), StringComparison.OrdinalIgnoreCase); using var request = CreateRequest(HttpMethod.Post, MakeAbsoluteUrl(settings.Backend.BaseUrl, path), settings); request.Content = JsonContent.Create(body); using var response = await _httpClient.SendAsync(request, cancellationToken); @@ -217,6 +248,12 @@ public sealed class BackendClient return new Bitmap(stream); } + private static bool IsRegisterablePrinter(PrinterConfig printer) + { + return !string.IsNullOrWhiteSpace(printer.PrinterId) + && !string.IsNullOrWhiteSpace(printer.WindowsPrinterName); + } + private static bool IsLabelJobAvailableEvent(string data) { if (string.IsNullOrWhiteSpace(data)) diff --git a/src/LabelPrintAgent/Backend/BackendLabelJob.cs b/src/LabelPrintAgent/Backend/BackendLabelJob.cs index 5e9f330..b3b3910 100644 --- a/src/LabelPrintAgent/Backend/BackendLabelJob.cs +++ b/src/LabelPrintAgent/Backend/BackendLabelJob.cs @@ -2,7 +2,10 @@ namespace LabelPrintAgent.Backend; public sealed class BackendLabelJob { - public string JobId { get; set; } = string.Empty; + public int JobId { get; set; } + public string PrinterId { get; set; } = string.Empty; + public string LabelType { get; set; } = string.Empty; + public string WindowsPrinterName { get; set; } = string.Empty; public string? LabelImageBase64 { get; set; } public string? LabelImageUrl { get; set; } public string LabelImageContentType { get; set; } = "image/png"; diff --git a/src/LabelPrintAgent/Backend/BackendPollingWorker.cs b/src/LabelPrintAgent/Backend/BackendPollingWorker.cs index 9bc6673..19afaa2 100644 --- a/src/LabelPrintAgent/Backend/BackendPollingWorker.cs +++ b/src/LabelPrintAgent/Backend/BackendPollingWorker.cs @@ -54,11 +54,26 @@ public sealed class BackendPollingWorker : IDisposable return; } - var printerName = settings.Printer.PrinterName; - if (string.IsNullOrWhiteSpace(printerName) || !_printerService.IsPrinterAvailable(printerName)) + var configuredPrinters = settings.Printers + .Where(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName)) + .ToList(); + if (configuredPrinters.Count == 0) { Log.Warning("No available printer configured for backend polling"); - SetStatus(false, "Kein verfügbarer Drucker konfiguriert."); + SetStatus(false, "Kein Drucker konfiguriert."); + return; + } + + await _backendClient.RegisterPrintersAsync(cancellationToken); + + var unavailablePrinters = configuredPrinters + .Where(printer => !_printerService.IsPrinterAvailable(printer.WindowsPrinterName)) + .Select(printer => printer.WindowsPrinterName) + .ToList(); + if (unavailablePrinters.Count == configuredPrinters.Count) + { + Log.Warning("No configured printer is available for backend polling: {Printers}", string.Join(", ", unavailablePrinters)); + SetStatus(false, "Kein konfigurierter Drucker verfügbar."); return; } @@ -76,7 +91,7 @@ public sealed class BackendPollingWorker : IDisposable } processedJobs++; - await ProcessJobAsync(job, printerName, cancellationToken); + await ProcessJobAsync(job, cancellationToken); } } catch (Exception ex) when (ex is not OperationCanceledException) @@ -90,10 +105,21 @@ public sealed class BackendPollingWorker : IDisposable } } - private async Task ProcessJobAsync(BackendLabelJob job, string printerName, CancellationToken cancellationToken) + private async Task ProcessJobAsync(BackendLabelJob job, CancellationToken cancellationToken) { + var printerName = job.WindowsPrinterName; try { + if (string.IsNullOrWhiteSpace(printerName)) + { + throw new InvalidOperationException("Der Druckjob enthält keinen windowsPrinterName."); + } + + if (!_printerService.IsPrinterAvailable(printerName)) + { + throw new InvalidOperationException($"Drucker '{printerName}' ist nicht verfügbar."); + } + SetStatus(true, $"Job {job.JobId} wird gedruckt."); using var bitmap = await _backendClient.GetLabelImageAsync(job, cancellationToken); var result = await _printerService.PrintAsync(bitmap, printerName, job.LabelWidthMm, job.LabelHeightMm, cancellationToken); @@ -110,7 +136,7 @@ public sealed class BackendPollingWorker : IDisposable catch (Exception ex) { Log.Error(ex, "Could not process backend label job {JobId}", job.JobId); - await _backendClient.ReportErrorAsync(job.JobId, printerName, ex.Message, cancellationToken); + await _backendClient.ReportErrorAsync(job.JobId, printerName ?? string.Empty, ex.Message, cancellationToken); SetStatus(false, $"Job {job.JobId} fehlgeschlagen: {ex.Message}"); } } diff --git a/src/LabelPrintAgent/Configuration/AppSettings.cs b/src/LabelPrintAgent/Configuration/AppSettings.cs index 936d44c..cfd82da 100644 --- a/src/LabelPrintAgent/Configuration/AppSettings.cs +++ b/src/LabelPrintAgent/Configuration/AppSettings.cs @@ -5,6 +5,7 @@ public sealed class AppSettings public BackendSettings Backend { get; set; } = new(); public UpdateSettings Updates { get; set; } = new(); public PrinterSettings Printer { get; set; } = new(); + public List Printers { get; set; } = []; public WorkerSettings Worker { get; set; } = new(); public PathSettings Paths { get; set; } = PathSettings.CreateDefault(); @@ -20,6 +21,7 @@ public sealed class BackendSettings public string BaseUrl { get; set; } = "https://paperlessmanager.local"; public string AgentId { get; set; } = Environment.MachineName; public string EncryptedApiToken { get; set; } = string.Empty; + public string RegisterPrinterPath { get; set; } = "/api/label-print-agent/printers/register"; 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"; @@ -43,6 +45,16 @@ public sealed class PrinterSettings public int Dpi { get; set; } = 300; } +public sealed class PrinterConfig +{ + public string PrinterId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string WindowsPrinterName { get; set; } = string.Empty; + public int Dpi { get; set; } = 203; + public int DefaultWidthMm { get; set; } = 57; + public int DefaultHeightMm { get; set; } = 32; +} + public sealed class WorkerSettings { public bool Enabled { get; set; } = true; diff --git a/src/LabelPrintAgent/Configuration/SettingsStore.cs b/src/LabelPrintAgent/Configuration/SettingsStore.cs index 3d43c45..bc92b87 100644 --- a/src/LabelPrintAgent/Configuration/SettingsStore.cs +++ b/src/LabelPrintAgent/Configuration/SettingsStore.cs @@ -41,7 +41,9 @@ public sealed class SettingsStore settings.Backend ??= new BackendSettings(); settings.Updates ??= new UpdateSettings(); settings.Printer ??= new PrinterSettings(); + settings.Printers ??= []; settings.Worker ??= new WorkerSettings(); + MigrateLegacyPrinter(settings); return settings; } catch (Exception ex) @@ -84,4 +86,33 @@ public sealed class SettingsStore return normalized.Trim().Trim('"', '\'').Trim(); } + + private static void MigrateLegacyPrinter(AppSettings settings) + { + if (settings.Printers.Count > 0 || string.IsNullOrWhiteSpace(settings.Printer.PrinterName)) + { + return; + } + + settings.Printers.Add(new PrinterConfig + { + PrinterId = BuildPrinterId(settings.Backend.AgentId, settings.Printer.PrinterName), + Name = settings.Printer.PrinterName, + WindowsPrinterName = settings.Printer.PrinterName, + Dpi = settings.Printer.Dpi, + DefaultWidthMm = Math.Max(1, (int)Math.Round(settings.Printer.LabelWidthMm)), + DefaultHeightMm = Math.Max(1, (int)Math.Round(settings.Printer.LabelHeightMm)) + }); + } + + public static string BuildPrinterId(string agentId, string windowsPrinterName) + { + var effectiveAgentId = string.IsNullOrWhiteSpace(agentId) ? Environment.MachineName : agentId.Trim(); + var printerPart = new string((windowsPrinterName ?? string.Empty) + .Trim() + .Select(character => char.IsLetterOrDigit(character) ? char.ToUpperInvariant(character) : '_') + .ToArray()); + printerPart = string.Join("_", printerPart.Split('_', StringSplitOptions.RemoveEmptyEntries)); + return $"{effectiveAgentId}_{(string.IsNullOrWhiteSpace(printerPart) ? "PRINTER" : printerPart)}"; + } }