diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..2b74cea
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "PrinterMonitor",
+ "runtimeExecutable": "dotnet",
+ "runtimeArgs": ["run"],
+ "port": 5000
+ }
+ ]
+}
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..88c7313
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,32 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(dotnet build:*)",
+ "Bash(dotnet restore:*)",
+ "Bash(python3:*)",
+ "Bash(grep -E \"\\\\.\\(cs|json|xaml\\)$|^d\")",
+ "Bash(git push:*)",
+ "Bash(git count-objects:*)",
+ "Bash(xargs -I{} git ls-files -s \"{}\")",
+ "Bash(sort -k4)",
+ "Bash(xargs -0 du -b)",
+ "Bash(git rm:*)",
+ "Bash(git add:*)",
+ "Bash(git commit:*)",
+ "Bash(git gc:*)",
+ "Bash(git remote:*)",
+ "Bash(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -T git@git.data-scales.de)",
+ "Bash(ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -T git@git.data-scales.de)",
+ "Bash(nslookup git.data-scales.de)",
+ "Bash(ssh -o ConnectTimeout=10 -p 22 -T git@git.data-scales.de)",
+ "Bash(ssh -o ConnectTimeout=10 -p 2222 -T git@git.data-scales.de)",
+ "Bash(ssh -o ConnectTimeout=15 -v -T git@git.data-scales.de)",
+ "Bash(ssh-keygen -Y sign -n gitea -f ~/.ssh/id_ed25519)",
+ "Bash(ssh -o ConnectTimeout=10 -p 443 -T git@git.data-scales.de)",
+ "Bash(ssh -o ConnectTimeout=10 -p 443 -T ssh.git.data-scales.de)",
+ "Bash(git checkout:*)",
+ "Bash(git -c user.email=a@a.a -c user.name=a commit -m \"chore: gitignore\" -q)",
+ "Bash(git -c user.email=a@a.a -c user.name=a commit -m \"feat: source code\" -q)"
+ ]
+ }
+}
diff --git a/App.xaml b/App.xaml
new file mode 100644
index 0000000..d6fbf23
--- /dev/null
+++ b/App.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/App.xaml.cs b/App.xaml.cs
new file mode 100644
index 0000000..e72fdff
--- /dev/null
+++ b/App.xaml.cs
@@ -0,0 +1,197 @@
+using System.IO;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Windows;
+using System.Windows.Forms;
+using PrinterMonitor.Configuration;
+using PrinterMonitor.Services;
+using PrinterMonitor.ViewModels;
+using PrinterMonitor.Views;
+
+namespace PrinterMonitor;
+
+public partial class App : System.Windows.Application
+{
+ private static Mutex? _singleInstanceMutex;
+ private AppSettings? _settings;
+ private PrinterService? _printerService;
+ private NotifyIcon? _notifyIcon;
+ private MainWindow? _mainWindow;
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ // Sicherstellen, dass nur eine Instanz läuft
+ _singleInstanceMutex = new Mutex(true, "DS-Soft-LTS-PrinterMonitor", out bool isNewInstance);
+ if (!isNewInstance)
+ {
+ System.Windows.MessageBox.Show(
+ "DS Soft-LTS läuft bereits.", "Bereits gestartet",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ Shutdown();
+ return;
+ }
+
+ // Konfiguration laden
+ var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
+
+ try
+ {
+ if (File.Exists(configPath))
+ {
+ var json = await File.ReadAllTextAsync(configPath);
+ _settings = JsonSerializer.Deserialize(json, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Fehler beim Laden der Konfiguration: {ex.Message}");
+ }
+
+ if (_settings == null)
+ {
+ System.Windows.MessageBox.Show("Konfigurationsdatei nicht gefunden/lesbar");
+ Shutdown();
+ return;
+ }
+
+ var validationErrors = _settings.Validate();
+ if (validationErrors.Count > 0)
+ {
+ var msg = "Konfigurationsfehler:\n\n" + string.Join("\n", validationErrors);
+ System.Windows.MessageBox.Show(msg, "Konfigurationsfehler",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+
+ // PrinterService starten (Monitoring + TCP-Push)
+ _printerService = new PrinterService(_settings);
+ await _printerService.StartAsync();
+
+ // Tray-Icon erstellen
+ CreateTrayIcon();
+ }
+
+ private void CreateTrayIcon()
+ {
+ System.Drawing.Icon? icon = null;
+ try
+ {
+ // Stream explizit disposen: Icon lädt Daten sofort, Stream danach nicht mehr nötig
+ using var stream = GetResourceStream(new Uri("pack://application:,,,/Resources/app.ico"))?.Stream;
+ if (stream != null)
+ icon = new System.Drawing.Icon(stream);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Tray-Icon konnte nicht geladen werden: {ex.Message}");
+ }
+
+ _notifyIcon = new System.Windows.Forms.NotifyIcon
+ {
+ Icon = icon,
+ Text = $"DS Soft-LTS v{MainViewModel.VersionString}",
+ Visible = true,
+ ContextMenuStrip = CreateTrayMenu()
+ };
+
+ _notifyIcon.DoubleClick += (s, e) => ShowWindow();
+ }
+
+ private ContextMenuStrip CreateTrayMenu()
+ {
+ var menu = new ContextMenuStrip();
+
+ var showItem = new ToolStripMenuItem("Anzeigen");
+ showItem.Click += (s, e) => ShowWindow();
+ menu.Items.Add(showItem);
+
+ menu.Items.Add(new ToolStripSeparator());
+
+ var exitItem = new ToolStripMenuItem("Beenden");
+ exitItem.Click += (s, e) => ExitApplication();
+ menu.Items.Add(exitItem);
+
+ return menu;
+ }
+
+ private void ShowWindow()
+ {
+ if (_mainWindow == null)
+ {
+ var mainVm = new MainViewModel(_printerService!, _settings!);
+ _mainWindow = new MainWindow { DataContext = mainVm };
+ _mainWindow.Closed += MainWindowClosed;
+ }
+
+ _mainWindow.Show();
+ _mainWindow.WindowState = WindowState.Normal;
+ _mainWindow.Activate();
+ }
+
+ private void ExitApplication()
+ {
+ // Fenster schließen ohne Hide-Abfangen
+ if (_mainWindow != null)
+ {
+ _mainWindow.Closed -= MainWindowClosed;
+ _mainWindow.Close();
+ _mainWindow = null;
+ }
+
+ // Tray-Icon entfernen (wird in OnExit erneut geprüft)
+ if (_notifyIcon != null)
+ {
+ _notifyIcon.Visible = false;
+ _notifyIcon.Dispose();
+ _notifyIcon = null;
+ }
+
+ // Shutdown() loest OnExit aus, das den Service synchron disposed.
+ Shutdown();
+ }
+
+ private void MainWindowClosed(object? sender, EventArgs e)
+ {
+ // DashboardViewModel disposen: stoppt den DispatcherTimer (verhindert Timer-Leak)
+ if (_mainWindow?.DataContext is MainViewModel vm)
+ vm.Dashboard.Dispose();
+ _mainWindow = null;
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ if (_notifyIcon != null)
+ {
+ _notifyIcon.Visible = false;
+ _notifyIcon.Dispose();
+ _notifyIcon = null;
+ }
+
+ try
+ {
+ // Timeout von 3 Sek.: SelectEndpoint (OPC UA) ist ein synchron-blockierender
+ // Netzwerkaufruf ohne CancellationToken – GetAwaiter().GetResult() würde
+ // ewig warten und Environment.Exit(0) wird nie erreicht.
+ // Task.Wait(TimeSpan) gibt nach spätestens 3 Sek. auf.
+ var disposeTask = _printerService?.DisposeAsync().AsTask() ?? Task.CompletedTask;
+ if (!disposeTask.Wait(TimeSpan.FromSeconds(3)))
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Warnung: PrinterService-Dispose nach 3 Sek. abgebrochen");
+ _printerService = null;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Fehler beim Beenden des PrinterService: {ex.Message}");
+ }
+
+ base.OnExit(e);
+
+ // Alle verbleibenden Foreground-Threads (z. B. OPC UA intern, Sockets)
+ // hart beenden - verhindert Zombie-Prozesse im Task-Manager.
+ Environment.Exit(0);
+ }
+}
diff --git a/BoolToVisibilityConverter.cs b/BoolToVisibilityConverter.cs
new file mode 100644
index 0000000..add40e6
--- /dev/null
+++ b/BoolToVisibilityConverter.cs
@@ -0,0 +1,15 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace PrinterMonitor;
+
+[ValueConversion(typeof(bool), typeof(Visibility))]
+public class BoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is true ? Visibility.Visible : Visibility.Collapsed;
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is Visibility.Visible;
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f4815ee
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,67 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Build & Run Commands
+
+```bash
+dotnet build # Build
+dotnet run # Run
+dotnet build -c Release
+dotnet restore # Restore NuGet packages
+```
+
+No tests, no linting configured.
+
+## Architecture
+
+**WPF desktop application (.NET 8.0, C#)** that monitors 3 contact states on industrial label printers and pushes state changes to TCP clients. Runs as a **tray application** — starts minimized to system tray, window opens on demand.
+
+### Three Monitored States
+
+Per printer, exactly 3 boolean states:
+1. **LTS Sensor** — Label Take-away Sensor
+2. **Druckerklappe** — Printhead flap open/closed
+3. **Keine Etiketten** — Paper/labels empty
+
+### Push-Based TCP Protocol
+
+Each printer gets its own TCP port (`tcpPort` in config). On state change, broadcasts `"ABC\n"` (3 chars, each `0`/`1`) to all connected clients. On connect, client receives current state immediately.
+
+### Printer Drivers
+
+- **CabSquix** (`Monitors/CabSquixMonitor.cs`): OPC UA via `SquixOpcUaClient` from `../OPC UA Test/`. Batch-reads 3 nodes in one call via `ReadAllSensors()`.
+- **Zebra** (`Monitors/ZebraMonitor.cs`): TCP/JSON on port 9200. Sends `{}{"sensor.peeler":null,"head.latch":null,"media.status":null}`, parses response values (`clear`/`not clear`, `ok`/`open`, `ok`/`out`).
+
+### App Lifecycle (Tray Icon)
+
+- `App.xaml`: `ShutdownMode="OnExplicitShutdown"` — app runs until explicit exit
+- `App.xaml.cs`: Creates `TaskbarIcon` (Hardcodet.NotifyIcon.Wpf.NetCore), starts `PrinterService` in background
+- Double-click tray → opens `MainWindow`; closing window → hides to tray
+- Right-click tray → "Anzeigen" / "Beenden"
+- `MainWindow.OnClosing` cancels close and hides instead
+
+### Data Flow
+
+1. `appsettings.json` → `PrinterService.StartAsync()` spawns per-printer: polling loop + `TcpPushServer`
+2. Poll returns `SimplePrinterState` (3 bools, immutable, `IEquatable`)
+3. Change detection → `TcpPushServer.BroadcastStateAsync()` on change
+4. `DashboardViewModel` refreshes every 500ms from status cache
+
+### GUI
+
+- Tab "Status": `DashboardView` with DataGrid (Name, Typ, Online, LTS, Klappe, Etiketten, TCP Port, Clients)
+- Tab "Einstellungen": `SettingsView` for printer CRUD, saves to `appsettings.json` (requires restart)
+
+### Key Dependencies
+
+- `../OPC UA Test/CabSquixOpcUaClient.csproj` — OPC UA client (`OPCFoundation.NetStandard.Opc.Ua`)
+- `Hardcodet.NotifyIcon.Wpf.NetCore` — System tray icon
+
+## Conventions
+
+- **Language**: UI text, comments, and XML docs are in German
+- **Nullable reference types** enabled project-wide
+- **Async/await with CancellationToken** throughout
+- **Configuration** in `appsettings.json` (camelCase keys), editable via GUI or directly
+- Adding a new printer type: implement `IPrinterMonitor`, register in `PrinterService.CreateMonitor()`
diff --git a/Configuration/AppSettings.cs b/Configuration/AppSettings.cs
new file mode 100644
index 0000000..ae61110
--- /dev/null
+++ b/Configuration/AppSettings.cs
@@ -0,0 +1,34 @@
+namespace PrinterMonitor.Configuration;
+
+public class AppSettings
+{
+ public List Printers { get; set; } = new();
+
+ /// TCP-Port, auf den der Client verbindet (Default: 12164).
+ public int TcpTargetPort { get; set; } = 12164;
+
+ ///
+ /// Prüft die Konfiguration und gibt eine Liste von Fehlern zurück (leer = gültig).
+ ///
+ public List Validate()
+ {
+ var errors = new List();
+
+ if (TcpTargetPort < 1 || TcpTargetPort > 65535)
+ errors.Add($"TcpTargetPort muss zwischen 1 und 65535 liegen (ist {TcpTargetPort})");
+
+ foreach (var printer in Printers)
+ errors.AddRange(printer.Validate());
+
+ var duplicateNames = Printers
+ .Where(p => !string.IsNullOrWhiteSpace(p.Name))
+ .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key);
+
+ foreach (var name in duplicateNames)
+ errors.Add($"Druckername '{name}' ist mehrfach vergeben");
+
+ return errors;
+ }
+}
diff --git a/Configuration/PrinterConfig.cs b/Configuration/PrinterConfig.cs
new file mode 100644
index 0000000..ef6b9e2
--- /dev/null
+++ b/Configuration/PrinterConfig.cs
@@ -0,0 +1,39 @@
+namespace PrinterMonitor.Configuration;
+
+public class PrinterConfig
+{
+ private static readonly HashSet ValidTypes = new(StringComparer.OrdinalIgnoreCase)
+ { "CabSquix", "Zebra", "Honeywell", "Simulation" };
+
+ public string Name { get; set; } = "";
+ public string Type { get; set; } = "CabSquix"; // "CabSquix", "Zebra", "Honeywell" oder "Simulation"
+ public string Host { get; set; } = "";
+ public int Port { get; set; } // Drucker-Kommunikationsport (bei Simulation ignoriert)
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Prüft die Konfiguration und gibt eine Liste von Fehlern zurück (leer = gültig).
+ ///
+ public List Validate()
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(Name))
+ errors.Add("Name darf nicht leer sein");
+
+ if (!ValidTypes.Contains(Type ?? ""))
+ errors.Add($"Ungültiger Druckertyp '{Type}'");
+
+ var isSimulation = string.Equals(Type, "Simulation", StringComparison.OrdinalIgnoreCase);
+ if (!isSimulation)
+ {
+ if (string.IsNullOrWhiteSpace(Host))
+ errors.Add($"'{Name}': Host darf nicht leer sein");
+
+ if (Port < 1 || Port > 65535)
+ errors.Add($"'{Name}': Port muss zwischen 1 und 65535 liegen (ist {Port})");
+ }
+
+ return errors;
+ }
+}
diff --git a/DATA-SCALES Logo 2017.png b/DATA-SCALES Logo 2017.png
new file mode 100644
index 0000000..c1469ed
Binary files /dev/null and b/DATA-SCALES Logo 2017.png differ
diff --git a/Interfaces/IPrinterMonitor.cs b/Interfaces/IPrinterMonitor.cs
new file mode 100644
index 0000000..ecbbbe7
--- /dev/null
+++ b/Interfaces/IPrinterMonitor.cs
@@ -0,0 +1,17 @@
+using PrinterMonitor.Models;
+
+namespace PrinterMonitor.Interfaces;
+
+///
+/// Abstraktion für die Kommunikation mit einem Drucker.
+/// Jeder Druckertyp (CAB Squix, Zebra, ...) implementiert dieses Interface.
+/// Gibt nur die drei überwachten Boolean-Zustände zurück.
+///
+public interface IPrinterMonitor : IAsyncDisposable
+{
+ string PrinterName { get; }
+ bool IsConnected { get; }
+ Task ConnectAsync(CancellationToken ct = default);
+ Task DisconnectAsync();
+ Task PollStateAsync(CancellationToken ct = default);
+}
diff --git a/Models/AggregatedPrinterState.cs b/Models/AggregatedPrinterState.cs
new file mode 100644
index 0000000..6a2a8f7
--- /dev/null
+++ b/Models/AggregatedPrinterState.cs
@@ -0,0 +1,38 @@
+using PrinterMonitor.Models;
+
+namespace PrinterMonitor.Models;
+
+///
+/// Berechnet den aggregierten (ODER-verknüpften) Druckerzustand
+/// über alle überwachten Drucker hinweg.
+///
+public static class AggregatedPrinterState
+{
+ ///
+ /// Gibt einen zurück, dessen Felder
+ /// jeweils das logische ODER aller übergebenen Einzelzustände sind.
+ /// Gibt null zurück wenn keine Zustände übergeben wurden.
+ ///
+ public static SimplePrinterState? Aggregate(IEnumerable states)
+ {
+ SimplePrinterState? result = null;
+
+ foreach (var s in states)
+ {
+ if (result == null)
+ {
+ result = s;
+ continue;
+ }
+
+ result = new SimplePrinterState
+ {
+ LtsSensor = result.LtsSensor || s.LtsSensor,
+ Druckerklappe = result.Druckerklappe || s.Druckerklappe,
+ KeineEtiketten = result.KeineEtiketten || s.KeineEtiketten
+ };
+ }
+
+ return result;
+ }
+}
diff --git a/Models/PrinterStatus.cs b/Models/PrinterStatus.cs
new file mode 100644
index 0000000..bfecb76
--- /dev/null
+++ b/Models/PrinterStatus.cs
@@ -0,0 +1,19 @@
+namespace PrinterMonitor.Models;
+
+///
+/// Vereinfachter Druckerstatus für die GUI-Anzeige.
+/// Kombiniert die drei überwachten Zustände mit Verbindungs-Metadaten.
+///
+public class PrinterStatus
+{
+ public string PrinterName { get; set; } = "";
+ public string PrinterType { get; set; } = "";
+ public bool IsOnline { get; set; }
+ public DateTime LastUpdated { get; set; }
+ public string? ErrorMessage { get; set; }
+
+ // Die drei überwachten Zustände
+ public bool? LtsSensor { get; set; }
+ public bool? Druckerklappe { get; set; }
+ public bool? KeineEtiketten { get; set; }
+}
diff --git a/Models/SimplePrinterState.cs b/Models/SimplePrinterState.cs
new file mode 100644
index 0000000..48c03ff
--- /dev/null
+++ b/Models/SimplePrinterState.cs
@@ -0,0 +1,36 @@
+namespace PrinterMonitor.Models;
+
+///
+/// Die drei überwachten Druckerzustände.
+/// Wird bei jedem Poll-Zyklus erzeugt und mit dem vorherigen Zustand verglichen.
+///
+public class SimplePrinterState : IEquatable
+{
+ /// LTS Sensor: true = Etikett steht an / Sensor belegt
+ public bool LtsSensor { get; init; }
+
+ /// Druckerklappe: true = Druckkopf offen
+ public bool Druckerklappe { get; init; }
+
+ /// Keine Etiketten: true = Papier leer/niedrig
+ public bool KeineEtiketten { get; init; }
+
+ ///
+ /// Erzeugt den VW-Protokoll-Payload (ohne STX/ETX-Framing).
+ /// V1=LTS-Sensor, V2=Druckerklappe, V3=Keine Etiketten, V4=Reserviert (immer 0).
+ /// Beispiel: "VW;V1=1,V2=0,V3=0,V4=0" = LTS belegt, Klappe zu, Papier vorhanden.
+ ///
+ public string ToTcpString() =>
+ $"VW;V1={(LtsSensor ? '1' : '0')},V2={(Druckerklappe ? '1' : '0')},V3={(KeineEtiketten ? '1' : '0')},V4=0";
+
+ public bool Equals(SimplePrinterState? other)
+ {
+ if (other is null) return false;
+ return LtsSensor == other.LtsSensor
+ && Druckerklappe == other.Druckerklappe
+ && KeineEtiketten == other.KeineEtiketten;
+ }
+
+ public override bool Equals(object? obj) => Equals(obj as SimplePrinterState);
+ public override int GetHashCode() => HashCode.Combine(LtsSensor, Druckerklappe, KeineEtiketten);
+}
diff --git a/Monitors/CabSquixMonitor.cs b/Monitors/CabSquixMonitor.cs
new file mode 100644
index 0000000..d1762b5
--- /dev/null
+++ b/Monitors/CabSquixMonitor.cs
@@ -0,0 +1,58 @@
+using PrinterMonitor.Configuration;
+using PrinterMonitor.Interfaces;
+using PrinterMonitor.Models;
+
+namespace PrinterMonitor.Monitors;
+
+///
+/// IPrinterMonitor-Implementierung für CAB Squix Drucker via OPC UA.
+/// Liest nur die drei überwachten Zustände.
+///
+public class CabSquixMonitor : IPrinterMonitor
+{
+ private readonly PrinterConfig _config;
+ private SquixOpcUaClient? _client;
+
+ public string PrinterName => _config.Name;
+ public bool IsConnected => _client?.IsConnected == true;
+
+ public CabSquixMonitor(PrinterConfig config)
+ {
+ _config = config;
+ }
+
+ public async Task ConnectAsync(CancellationToken ct = default)
+ {
+ _client = new SquixOpcUaClient(_config.Host, _config.Port);
+ await _client.ConnectAsync(ct);
+ }
+
+ public async Task DisconnectAsync()
+ {
+ if (_client != null)
+ {
+ await _client.DisposeAsync();
+ _client = null;
+ }
+ }
+
+ public async Task PollStateAsync(CancellationToken ct = default)
+ {
+ if (_client == null || !_client.IsConnected)
+ throw new InvalidOperationException("Nicht verbunden");
+
+ var (lts, printhead, paperLow) = await _client.ReadAllSensorsAsync(ct);
+
+ return new SimplePrinterState
+ {
+ LtsSensor = lts,
+ Druckerklappe = printhead,
+ KeineEtiketten = paperLow
+ };
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisconnectAsync();
+ }
+}
diff --git a/Monitors/HoneywellMonitor.cs b/Monitors/HoneywellMonitor.cs
new file mode 100644
index 0000000..6e3a993
--- /dev/null
+++ b/Monitors/HoneywellMonitor.cs
@@ -0,0 +1,184 @@
+using System.IO;
+using System.Net.Sockets;
+using System.Text;
+using PrinterMonitor.Configuration;
+using PrinterMonitor.Interfaces;
+using PrinterMonitor.Models;
+
+namespace PrinterMonitor.Monitors;
+
+///
+/// IPrinterMonitor-Implementierung für Honeywell PM45 Drucker.
+///
+/// Protokoll (ASCII über TCP, Standard-Port 9201):
+/// Beim Verbindungsaufbau sendet der Drucker automatisch Statuszeilen:
+/// LTS:[0|1];HEAD:[0|1];MEDIA:[0|1] + LF (0x0A)
+///
+/// Werte-Mapping:
+/// LTS: 0 = Sensor frei, 1 = Etikett steht an
+/// HEAD: 0 = Klappe geschlossen, 1 = Druckkopf offen
+/// MEDIA: 0 = Etiketten vorhanden, 1 = Papier leer
+///
+/// Der Monitor verbindet sich als TCP-Client und liest eingehende Zeilen
+/// im Hintergrund. PollStateAsync() gibt den zuletzt empfangenen Zustand zurück.
+///
+public class HoneywellMonitor : IPrinterMonitor
+{
+ private readonly PrinterConfig _config;
+ private TcpClient? _tcpClient;
+ private StreamReader? _reader;
+ private CancellationTokenSource? _readCts;
+ private Task? _readTask;
+
+ // Letzter empfangener Zustand – wird vom Lese-Thread aktualisiert
+ private volatile SimplePrinterState _lastState = new();
+
+ // Wird auf true gesetzt sobald der Lese-Thread ein EOF / Disconnect erkennt
+ private volatile bool _isDisconnected = true;
+
+ public string PrinterName => _config.Name;
+ public bool IsConnected => _tcpClient?.Connected == true && !_isDisconnected;
+
+ public HoneywellMonitor(PrinterConfig config)
+ {
+ _config = config;
+ }
+
+ public async Task ConnectAsync(CancellationToken ct = default)
+ {
+ _isDisconnected = false;
+ var client = new TcpClient();
+ try
+ {
+ await client.ConnectAsync(_config.Host, _config.Port, ct);
+
+ _reader = new StreamReader(
+ client.GetStream(), Encoding.ASCII, false, 256, leaveOpen: true);
+
+ _tcpClient = client;
+ _readCts = new CancellationTokenSource();
+ _readTask = ReadLoopAsync(_readCts.Token);
+ }
+ catch
+ {
+ client.Dispose();
+ _isDisconnected = true;
+ throw;
+ }
+ }
+
+ public async Task DisconnectAsync()
+ {
+ _readCts?.Cancel();
+
+ if (_readTask != null)
+ {
+ try { await _readTask; } catch { }
+ _readTask = null;
+ }
+
+ _readCts?.Dispose();
+ _readCts = null;
+ _reader?.Dispose();
+ _reader = null;
+ _tcpClient?.Dispose();
+ _tcpClient = null;
+ }
+
+ public Task PollStateAsync(CancellationToken ct = default)
+ {
+ if (_isDisconnected || _tcpClient == null || !_tcpClient.Connected)
+ throw new IOException("Verbindung zum Honeywell-Drucker getrennt");
+
+ return Task.FromResult(_lastState);
+ }
+
+ ///
+ /// Liest Statuszeilen vom Drucker im Hintergrund.
+ /// Format: LTS:0;HEAD:0;MEDIA:0
+ ///
+ private async Task ReadLoopAsync(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested && _reader != null)
+ {
+ var line = await _reader.ReadLineAsync(ct);
+ if (line == null)
+ {
+ // Drucker hat die Verbindung geschlossen (EOF)
+ _isDisconnected = true;
+ break;
+ }
+
+ var parsed = ParseStatusLine(line);
+ if (parsed != null)
+ _lastState = parsed;
+ else
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {PrinterName}: Ungültige Statuszeile: {line}");
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (IOException)
+ {
+ // Netzwerkfehler → als Disconnect markieren
+ _isDisconnected = true;
+ }
+ catch (ObjectDisposedException) { }
+ }
+
+ ///
+ /// Parst eine Honeywell-Statuszeile: LTS:0;HEAD:0;MEDIA:0
+ /// Gibt null zurück wenn das Format ungültig ist.
+ ///
+ private static SimplePrinterState? ParseStatusLine(string line)
+ {
+ // Beispiel: "LTS:0;HEAD:0;MEDIA:0"
+ line = line.Trim();
+ if (string.IsNullOrEmpty(line)) return null;
+
+ bool lts = false, head = false, media = false;
+ bool foundLts = false, foundHead = false, foundMedia = false;
+
+ var parts = line.Split(';');
+ foreach (var part in parts)
+ {
+ var kv = part.Split(':');
+ if (kv.Length != 2) continue;
+
+ var key = kv[0].Trim().ToUpperInvariant();
+ var val = kv[1].Trim();
+
+ switch (key)
+ {
+ case "LTS":
+ lts = val == "1";
+ foundLts = true;
+ break;
+ case "HEAD":
+ head = val == "1";
+ foundHead = true;
+ break;
+ case "MEDIA":
+ media = val == "1";
+ foundMedia = true;
+ break;
+ }
+ }
+
+ if (!foundLts || !foundHead || !foundMedia)
+ return null;
+
+ return new SimplePrinterState
+ {
+ LtsSensor = lts,
+ Druckerklappe = head,
+ KeineEtiketten = media
+ };
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisconnectAsync();
+ }
+}
diff --git a/Monitors/SimulationMonitor.cs b/Monitors/SimulationMonitor.cs
new file mode 100644
index 0000000..34598f3
--- /dev/null
+++ b/Monitors/SimulationMonitor.cs
@@ -0,0 +1,45 @@
+using PrinterMonitor.Configuration;
+using PrinterMonitor.Interfaces;
+using PrinterMonitor.Models;
+
+namespace PrinterMonitor.Monitors;
+
+///
+/// Simulation-Drucker: kein Netzwerk, kein OPC UA.
+/// Die Sensor-Zustände werden manuell über SetState() gesetzt.
+/// PollStateAsync() gibt immer sofort den aktuellen manuellen Zustand zurück.
+///
+public class SimulationMonitor : IPrinterMonitor
+{
+ private SimplePrinterState _state = new();
+
+ public string PrinterName { get; }
+ public bool IsConnected => true;
+
+ public SimulationMonitor(PrinterConfig config)
+ {
+ PrinterName = config.Name;
+ }
+
+ public void SetState(bool ltsSensor, bool druckerklappe, bool keineEtiketten)
+ {
+ _state = new SimplePrinterState
+ {
+ LtsSensor = ltsSensor,
+ Druckerklappe = druckerklappe,
+ KeineEtiketten = keineEtiketten
+ };
+ }
+
+ public Task ConnectAsync(CancellationToken ct = default)
+ => Task.CompletedTask;
+
+ public Task DisconnectAsync()
+ => Task.CompletedTask;
+
+ public Task PollStateAsync(CancellationToken ct = default)
+ => Task.FromResult(_state);
+
+ public ValueTask DisposeAsync()
+ => ValueTask.CompletedTask;
+}
diff --git a/Monitors/SquixOpcUaClient.cs b/Monitors/SquixOpcUaClient.cs
new file mode 100644
index 0000000..41f6903
--- /dev/null
+++ b/Monitors/SquixOpcUaClient.cs
@@ -0,0 +1,204 @@
+using System.IO;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Opc.Ua.Configuration;
+
+namespace PrinterMonitor.Monitors;
+
+///
+/// Minimaler OPC UA Client für CAB Squix Etikettendrucker.
+/// Liest ausschließlich die drei überwachten Sensorzustände:
+/// LTS-Sensor (ns=3;i=22002), Druckkopf-Klappe (ns=3;i=10076), Papier leer (ns=3;i=10019).
+///
+/// Konfiguration und CertificateValidator werden einmalig im Konstruktor
+/// erstellt (nicht bei jedem ConnectAsync neu), um unnötige Allokationen
+/// und Event-Leaks zu vermeiden.
+///
+internal sealed class SquixOpcUaClient : IAsyncDisposable
+{
+ private const int OperationTimeoutMs = 15000;
+ private const int SessionTimeoutMs = 60000;
+
+ private readonly string _endpointUrl;
+ private readonly ApplicationConfiguration _appConfig;
+ private ISession? _session;
+ private bool _disposed;
+
+ // Namespace-Indizes – werden beim Connect dynamisch ermittelt
+ private ushort _nsPrinter = 3; // cab.de/Printer
+
+ public bool IsConnected => _session?.Connected == true;
+
+ public SquixOpcUaClient(string host, int port = 4840)
+ {
+ _endpointUrl = $"opc.tcp://{host}:{port}";
+
+ // Einmalige Konfiguration – kein Re-Allokieren bei jedem ConnectAsync
+ _appConfig = new ApplicationConfiguration
+ {
+ ApplicationName = "PrinterMonitor",
+ ApplicationUri = "urn:PrinterMonitor",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ ApplicationCertificate = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(AppContext.BaseDirectory, "pki", "own")
+ },
+ TrustedIssuerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(AppContext.BaseDirectory, "pki", "issuer")
+ },
+ TrustedPeerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(AppContext.BaseDirectory, "pki", "trusted")
+ },
+ RejectedCertificateStore = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(AppContext.BaseDirectory, "pki", "rejected")
+ },
+ AutoAcceptUntrustedCertificates = true,
+ AddAppCertToTrustedStore = false
+ },
+ TransportConfigurations = new TransportConfigurationCollection(),
+ TransportQuotas = new TransportQuotas { OperationTimeout = OperationTimeoutMs },
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = SessionTimeoutMs }
+ };
+
+ // CertificateValidator einmalig erstellen und Event einmalig subscriben (kein Leak).
+ // Obsolete-Warning für parameterlosem Ctor unterdrückt – ITelemetryContext ist
+ // in dieser Anwendung nicht verfügbar, die OPC UA Lib nutzt diesen Pfad selbst.
+#pragma warning disable CS0618
+ _appConfig.CertificateValidator = new CertificateValidator();
+#pragma warning restore CS0618
+ _appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
+ }
+
+ private static void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
+ => e.Accept = true;
+
+ public async Task ConnectAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ await _appConfig.ValidateAsync(ApplicationType.Client);
+
+ // SelectEndpoint ist synchron-blockierend ohne CancellationToken-Support.
+ // Task.Run verhindert, dass der aufrufende Thread blockiert wird.
+#pragma warning disable CS0618
+ var endpoint = await Task.Run(() =>
+ {
+ ct.ThrowIfCancellationRequested();
+ var ep = CoreClientUtils.SelectEndpoint(_appConfig, _endpointUrl, useSecurity: false);
+ ct.ThrowIfCancellationRequested();
+ return ep;
+ }, ct);
+
+ var configuredEndpoint = new ConfiguredEndpoint(
+ null, endpoint, EndpointConfiguration.Create(_appConfig));
+
+ _session = await Session.Create(
+ _appConfig, configuredEndpoint,
+ updateBeforeConnect: false,
+ sessionName: "PrinterMonitorSession",
+ sessionTimeout: SessionTimeoutMs,
+ identity: new UserIdentity(new AnonymousIdentityToken()),
+ preferredLocales: null);
+#pragma warning restore CS0618
+
+ ResolveNamespaceIndices();
+ }
+ catch
+ {
+ // Bei Fehler Session sauber wegräumen, damit interne OPC UA Threads nicht leaken
+ await DisconnectAsync();
+ throw;
+ }
+ }
+
+ public async Task DisconnectAsync()
+ {
+ if (_session != null)
+ {
+ try
+ {
+ if (_session.Connected)
+ await _session.CloseAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] OPC UA: Fehler beim Schließen der Session – {ex.Message}");
+ }
+ finally
+ {
+ _session.Dispose();
+ _session = null;
+ }
+ }
+ }
+
+ ///
+ /// Liest LTS-Sensor, Druckkopf-Klappe und Papier-Leer in einem einzigen OPC UA Batch-Call.
+ ///
+ public async Task<(bool LtsSensor, bool PrintheadOpen, bool PaperLow)> ReadAllSensorsAsync(
+ CancellationToken ct = default)
+ {
+ var nodeIds = new[]
+ {
+ new NodeId(22002, _nsPrinter), // LTS Sensor
+ new NodeId(10076, _nsPrinter), // Printhead Open
+ new NodeId(10019, _nsPrinter), // Paper Low
+ };
+
+ var results = await ReadValuesAsync(nodeIds, ct);
+
+ return (
+ LtsSensor: results[0] is bool b0 ? b0 : Convert.ToBoolean(results[0] ?? false),
+ PrintheadOpen: results[1] is bool b1 ? b1 : Convert.ToBoolean(results[1] ?? false),
+ PaperLow: results[2] is bool b2 ? b2 : Convert.ToBoolean(results[2] ?? false)
+ );
+ }
+
+ private void ResolveNamespaceIndices()
+ {
+ if (_session == null) return;
+ var nsTable = _session.NamespaceUris;
+ for (int i = 0; i < nsTable.Count; i++)
+ {
+ string uri = nsTable.GetString((uint)i).TrimEnd('/');
+ if (uri.EndsWith("cab.de/Printer", StringComparison.OrdinalIgnoreCase))
+ _nsPrinter = (ushort)i;
+ }
+ }
+
+ private async Task