From 5a4bacd5ca043c6de55f090aecd1a5128fe9abbd Mon Sep 17 00:00:00 2001 From: "m.urhan" Date: Sun, 12 Apr 2026 17:39:45 +0200 Subject: [PATCH] Initial commit --- .claude/launch.json | 11 + .claude/settings.local.json | 32 ++ .gitignore | 42 +++ App.xaml | 9 + App.xaml.cs | 197 +++++++++++++ BoolToVisibilityConverter.cs | 15 + CLAUDE.md | 67 +++++ Configuration/AppSettings.cs | 34 +++ Configuration/PrinterConfig.cs | 39 +++ DATA-SCALES Logo 2017.png | Bin 0 -> 33511 bytes Interfaces/IPrinterMonitor.cs | 17 ++ Models/AggregatedPrinterState.cs | 38 +++ Models/PrinterStatus.cs | 19 ++ Models/SimplePrinterState.cs | 36 +++ Monitors/CabSquixMonitor.cs | 58 ++++ Monitors/HoneywellMonitor.cs | 184 ++++++++++++ Monitors/SimulationMonitor.cs | 45 +++ Monitors/SquixOpcUaClient.cs | 204 +++++++++++++ Monitors/ZebraMonitor.cs | 153 ++++++++++ PrinterMonitor.csproj | 76 +++++ PrinterMonitor.sln | 24 ++ Resources/app.ico | Bin 0 -> 8672 bytes Resources/printer.ico | Bin 0 -> 1150 bytes Services/PrinterService.cs | 276 ++++++++++++++++++ Tcp/TcpPushClient.cs | 137 +++++++++ ViewModels/DashboardViewModel.cs | 85 ++++++ ViewModels/MainViewModel.cs | 24 ++ ViewModels/PrinterStatusViewModel.cs | 112 +++++++ ViewModels/RelayCommand.cs | 60 ++++ ViewModels/SettingsViewModel.cs | 187 ++++++++++++ ViewModels/ViewModelBase.cs | 23 ++ Views/DashboardView.xaml | 81 +++++ Views/DashboardView.xaml.cs | 11 + Views/MainWindow.xaml | 16 + Views/MainWindow.xaml.cs | 19 ++ Views/SettingsView.xaml | 130 +++++++++ Views/SettingsView.xaml.cs | 11 + appsettings.json | 19 ++ lib/BitFaster.Caching.dll | Bin 0 -> 164864 bytes ...sions.DependencyInjection.Abstractions.dll | Bin 0 -> 65288 bytes ...crosoft.Extensions.DependencyInjection.dll | Bin 0 -> 94992 bytes ...rosoft.Extensions.Logging.Abstractions.dll | Bin 0 -> 66352 bytes lib/Microsoft.Extensions.Logging.dll | Bin 0 -> 50992 bytes lib/Microsoft.Extensions.Options.dll | Bin 0 -> 64824 bytes lib/Microsoft.Extensions.Primitives.dll | Bin 0 -> 44344 bytes lib/Newtonsoft.Json.dll | Bin 0 -> 723368 bytes lib/Opc.Ua.Client.dll | Bin 0 -> 846976 bytes lib/Opc.Ua.Configuration.dll | Bin 0 -> 84608 bytes lib/Opc.Ua.Core.dll | Bin 0 -> 7865984 bytes lib/Opc.Ua.Security.Certificates.dll | Bin 0 -> 79488 bytes lib/Opc.Ua.Types.dll | Bin 0 -> 1119360 bytes lib/System.Collections.Immutable.dll | Bin 0 -> 240904 bytes lib/System.Diagnostics.DiagnosticSource.dll | Bin 0 -> 192264 bytes lib/System.Formats.Asn1.dll | Bin 0 -> 96048 bytes lib/System.IO.Pipelines.dll | Bin 0 -> 77576 bytes 55 files changed, 2491 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 App.xaml create mode 100644 App.xaml.cs create mode 100644 BoolToVisibilityConverter.cs create mode 100644 CLAUDE.md create mode 100644 Configuration/AppSettings.cs create mode 100644 Configuration/PrinterConfig.cs create mode 100644 DATA-SCALES Logo 2017.png create mode 100644 Interfaces/IPrinterMonitor.cs create mode 100644 Models/AggregatedPrinterState.cs create mode 100644 Models/PrinterStatus.cs create mode 100644 Models/SimplePrinterState.cs create mode 100644 Monitors/CabSquixMonitor.cs create mode 100644 Monitors/HoneywellMonitor.cs create mode 100644 Monitors/SimulationMonitor.cs create mode 100644 Monitors/SquixOpcUaClient.cs create mode 100644 Monitors/ZebraMonitor.cs create mode 100644 PrinterMonitor.csproj create mode 100644 PrinterMonitor.sln create mode 100644 Resources/app.ico create mode 100644 Resources/printer.ico create mode 100644 Services/PrinterService.cs create mode 100644 Tcp/TcpPushClient.cs create mode 100644 ViewModels/DashboardViewModel.cs create mode 100644 ViewModels/MainViewModel.cs create mode 100644 ViewModels/PrinterStatusViewModel.cs create mode 100644 ViewModels/RelayCommand.cs create mode 100644 ViewModels/SettingsViewModel.cs create mode 100644 ViewModels/ViewModelBase.cs create mode 100644 Views/DashboardView.xaml create mode 100644 Views/DashboardView.xaml.cs create mode 100644 Views/MainWindow.xaml create mode 100644 Views/MainWindow.xaml.cs create mode 100644 Views/SettingsView.xaml create mode 100644 Views/SettingsView.xaml.cs create mode 100644 appsettings.json create mode 100644 lib/BitFaster.Caching.dll create mode 100644 lib/Microsoft.Extensions.DependencyInjection.Abstractions.dll create mode 100644 lib/Microsoft.Extensions.DependencyInjection.dll create mode 100644 lib/Microsoft.Extensions.Logging.Abstractions.dll create mode 100644 lib/Microsoft.Extensions.Logging.dll create mode 100644 lib/Microsoft.Extensions.Options.dll create mode 100644 lib/Microsoft.Extensions.Primitives.dll create mode 100644 lib/Newtonsoft.Json.dll create mode 100644 lib/Opc.Ua.Client.dll create mode 100644 lib/Opc.Ua.Configuration.dll create mode 100644 lib/Opc.Ua.Core.dll create mode 100644 lib/Opc.Ua.Security.Certificates.dll create mode 100644 lib/Opc.Ua.Types.dll create mode 100644 lib/System.Collections.Immutable.dll create mode 100644 lib/System.Diagnostics.DiagnosticSource.dll create mode 100644 lib/System.Formats.Asn1.dll create mode 100644 lib/System.IO.Pipelines.dll 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/.gitignore b/.gitignore new file mode 100644 index 0000000..be22573 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +## .NET / Visual Studio +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates + +## Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Bb]uild/ +[Bb]uilds/ +[Oo]ut/ + +## NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +!packages/build/ +*.nuget.props +*.nuget.targets + +## VS cache +.vs/ +*.VisualState.xml +*.cache +*.log + +## Rider / ReSharper +.idea/ +*.DotSettings.user +_ReSharper*/ + +## OS +Thumbs.db +ehthumbs.db +Desktop.ini +.DS_Store 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 0000000000000000000000000000000000000000..c1469ed81edb33a949ffd7285cd7b400ed30c67d GIT binary patch literal 33511 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 +/// 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 ReadValuesAsync(NodeId[] nodeIds, CancellationToken ct) + { + if (_session == null) throw new InvalidOperationException("Nicht verbunden."); + + var nodesToRead = new ReadValueIdCollection(); + foreach (var nid in nodeIds) + nodesToRead.Add(new ReadValueId { NodeId = nid, AttributeId = Attributes.Value }); + + var response = await _session.ReadAsync( + null, 0, TimestampsToReturn.Neither, nodesToRead, ct); + + var dataValues = response.Results; + var results = new object?[dataValues.Count]; + for (int i = 0; i < dataValues.Count; i++) + results[i] = dataValues[i].StatusCode == StatusCodes.Good ? dataValues[i].Value : null; + + return results; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _appConfig.CertificateValidator.CertificateValidation -= OnCertificateValidation; + await DisconnectAsync(); + _disposed = true; + } +} diff --git a/Monitors/ZebraMonitor.cs b/Monitors/ZebraMonitor.cs new file mode 100644 index 0000000..217509f --- /dev/null +++ b/Monitors/ZebraMonitor.cs @@ -0,0 +1,153 @@ +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using PrinterMonitor.Configuration; +using PrinterMonitor.Interfaces; +using PrinterMonitor.Models; + +namespace PrinterMonitor.Monitors; + +/// +/// IPrinterMonitor-Implementierung für Zebra Drucker via TCP mit JSON-Nachrichten. +/// +/// Protokoll (Zebra SGD über TCP, Standard-Port 9200): +/// Senden: {}{\"sensor.peeler\":null,\"head.latch\":null,\"media.status\":null} +/// Empfangen: {\"sensor.peeler\":\"clear\",\"head.latch\":\"ok\",\"media.status\":\"ok\"} +/// +/// Werte-Mapping: +/// sensor.peeler: "clear" = LTS frei, "not clear" = LTS belegt +/// head.latch: "ok" = Klappe geschlossen, "open" = Klappe offen +/// media.status: "ok" = Etiketten vorhanden, "out" = Etiketten leer +/// +public class ZebraMonitor : IPrinterMonitor +{ + private static readonly string Query = + "{}{\"sensor.peeler\":null,\"head.latch\":null,\"media.status\":null}"; + + // 256-Byte-Lesepuffer: reduziert System-Calls drastisch gegenüber 1-Byte-Buffer. + // Als Instanzfeld gecacht – keine Allokation pro Poll-Zyklus. + private readonly byte[] _readBuffer = new byte[256]; + + // StringBuilder als Instanzfeld: wird bei jedem Poll nur geleert, nie neu allokiert. + private readonly StringBuilder _responseBuilder = new(256); + + private readonly PrinterConfig _config; + private TcpClient? _tcpClient; + private NetworkStream? _stream; + + // Wird auf true gesetzt wenn ein Read/Write-Fehler oder EOF erkannt wird + private volatile bool _isDisconnected = true; + + public string PrinterName => _config.Name; + public bool IsConnected => _tcpClient?.Connected == true && !_isDisconnected; + + public ZebraMonitor(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); + _stream = client.GetStream(); + _tcpClient = client; + } + catch + { + client.Dispose(); + _isDisconnected = true; + throw; + } + } + + public Task DisconnectAsync() + { + _stream?.Dispose(); + _tcpClient?.Dispose(); + _stream = null; + _tcpClient = null; + return Task.CompletedTask; + } + + public async Task PollStateAsync(CancellationToken ct = default) + { + if (_isDisconnected || _tcpClient == null || _stream == null || !_tcpClient.Connected) + throw new IOException("Verbindung zum Zebra-Drucker getrennt"); + + // Anfrage senden + var queryBytes = Encoding.UTF8.GetBytes(Query); + await _stream.WriteAsync(queryBytes, ct); + await _stream.FlushAsync(ct); + + // Antwort lesen bis vollständiges JSON-Objekt + var json = await ReadJsonResponseAsync(ct); + + // JSON parsen + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var peeler = root.GetProperty("sensor.peeler").GetString() ?? ""; + var latch = root.GetProperty("head.latch").GetString() ?? ""; + var media = root.GetProperty("media.status").GetString() ?? ""; + + return new SimplePrinterState + { + LtsSensor = !peeler.Equals("clear", StringComparison.OrdinalIgnoreCase), + Druckerklappe = latch.Equals("open", StringComparison.OrdinalIgnoreCase), + KeineEtiketten = media.Equals("out", StringComparison.OrdinalIgnoreCase) + }; + } + + /// + /// Liest die JSON-Antwort vom Stream bis die schließende Klammer } erreicht ist. + /// Nutzt einen 256-Byte-Puffer statt byteweisem Lesen, um System-Calls zu minimieren. + /// Zebra sendet CRLF innerhalb des JSON — das ist gültiger JSON-Whitespace. + /// + private async Task ReadJsonResponseAsync(CancellationToken ct) + { + _responseBuilder.Clear(); + int braceDepth = 0; + bool started = false; + bool done = false; + + while (!ct.IsCancellationRequested && !done) + { + var bytesRead = await _stream!.ReadAsync(_readBuffer, ct); + if (bytesRead == 0) + { + _isDisconnected = true; + throw new IOException("Verbindung vom Zebra-Drucker geschlossen"); + } + + for (int i = 0; i < bytesRead && !done; i++) + { + char c = (char)_readBuffer[i]; + _responseBuilder.Append(c); + + if (c == '{') + { + braceDepth++; + started = true; + } + else if (c == '}') + { + braceDepth--; + if (started && braceDepth == 0) + done = true; + } + } + } + + return _responseBuilder.ToString(); + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(); + } +} diff --git a/PrinterMonitor.csproj b/PrinterMonitor.csproj new file mode 100644 index 0000000..e425448 --- /dev/null +++ b/PrinterMonitor.csproj @@ -0,0 +1,76 @@ + + + + WinExe + net8.0-windows + true + true + enable + enable + PrinterMonitor + PrinterMonitor + 1.0.2 + Resources\app.ico + + + + + + + + + + + + + + + + + + + $(MSBuildThisFileDirectory)lib\Opc.Ua.Core.dll + + + $(MSBuildThisFileDirectory)lib\Opc.Ua.Client.dll + + + $(MSBuildThisFileDirectory)lib\Opc.Ua.Configuration.dll + + + $(MSBuildThisFileDirectory)lib\Opc.Ua.Types.dll + + + $(MSBuildThisFileDirectory)lib\Opc.Ua.Security.Certificates.dll + + + $(MSBuildThisFileDirectory)lib\BitFaster.Caching.dll + + + $(MSBuildThisFileDirectory)lib\Newtonsoft.Json.dll + + + $(MSBuildThisFileDirectory)lib\System.Formats.Asn1.dll + + + $(MSBuildThisFileDirectory)lib\System.Diagnostics.DiagnosticSource.dll + + + $(MSBuildThisFileDirectory)lib\System.Collections.Immutable.dll + + + $(MSBuildThisFileDirectory)lib\System.IO.Pipelines.dll + + + + + + + + + + PreserveNewest + + + + diff --git a/PrinterMonitor.sln b/PrinterMonitor.sln new file mode 100644 index 0000000..360ef9d --- /dev/null +++ b/PrinterMonitor.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrinterMonitor", "PrinterMonitor.csproj", "{1F1335AE-6979-06AB-B80F-04E95495F5C4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1F1335AE-6979-06AB-B80F-04E95495F5C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F1335AE-6979-06AB-B80F-04E95495F5C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F1335AE-6979-06AB-B80F-04E95495F5C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F1335AE-6979-06AB-B80F-04E95495F5C4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4D599B26-4834-42EC-8C67-FED1715F0092} + EndGlobalSection +EndGlobal diff --git a/Resources/app.ico b/Resources/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..be1370747c3da23c90ea88419543e545c41f2627 GIT binary patch literal 8672 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 l_vxXM{r{IZ#z>mVz0t0*QX6q! +/// Verwaltet alle konfigurierten Drucker: +/// - Erzeugt Monitor pro Drucker, pollt mit festem Intervall (300 ms) +/// - Aggregiert alle Druckerzustände per ODER-Verknüpfung +/// - Pusht bei Zustandsänderung sofort, sendet alle 500 ms einen Heartbeat +/// - Ein einziger TCP-Client verbindet sich auf localhost:TcpTargetPort (Default 12164) +/// +public class PrinterService : IAsyncDisposable +{ + private const int PollingIntervalMs = 300; + private const int ReconnectDelayMs = 5000; + private const int HeartbeatIntervalMs = 500; + + private readonly AppSettings _settings; + private readonly ConcurrentDictionary _monitors = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _statusCache = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _lastStates = new(StringComparer.OrdinalIgnoreCase); + private TcpPushClient? _tcpClient; + private SimplePrinterState? _lastAggregatedState; + private readonly object _aggregationLock = new(); + private CancellationTokenSource? _cts; + private readonly List _pollingTasks = new(); + + public PrinterService(AppSettings settings) + { + _settings = settings; + } + + public async Task StartAsync() + { + _cts = new CancellationTokenSource(); + + _tcpClient = new TcpPushClient("localhost", _settings.TcpTargetPort); + _tcpClient.Start(); + + foreach (var config in _settings.Printers.Where(p => p.Enabled)) + { + var monitor = CreateMonitor(config); + _monitors[config.Name] = monitor; + + _statusCache[config.Name] = new PrinterStatus + { + PrinterName = config.Name, + PrinterType = config.Type, + IsOnline = false, + ErrorMessage = "Noch nicht abgefragt" + }; + _lastStates[config.Name] = null; + + _pollingTasks.Add(RunPollingLoop(config, monitor, _cts.Token)); + } + + _pollingTasks.Add(PeriodicBroadcastLoop(_cts.Token)); + await Task.CompletedTask; + } + + public async Task StopAsync() + { + _cts?.Cancel(); + + try { await Task.WhenAll(_pollingTasks); } + catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Fehler beim Stoppen der Polling-Tasks: {ex.Message}"); } + _pollingTasks.Clear(); + + foreach (var monitor in _monitors.Values) + { + try { await monitor.DisconnectAsync(); } + catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Fehler beim Trennen von {monitor.PrinterName}: {ex.Message}"); } + } + _monitors.Clear(); + _lastStates.Clear(); + _statusCache.Clear(); + _lastAggregatedState = null; + + if (_tcpClient != null) + { + try { await _tcpClient.DisposeAsync(); } + catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Fehler beim Stoppen des TCP-Clients: {ex.Message}"); } + _tcpClient = null; + } + } + + /// + /// Startet das Monitoring mit der aktuellen Konfiguration neu. + /// Wird nach dem Speichern der Einstellungen aufgerufen. + /// + public async Task RestartAsync() + { + await StopAsync(); + await StartAsync(); + } + + public IReadOnlyList GetPrinterNames() + => _statusCache.Keys.ToList().AsReadOnly(); + + public PrinterStatus? GetStatus(string printerName) + { + _statusCache.TryGetValue(printerName, out var status); + return status; + } + + /// + /// Setzt den Zustand eines Simulation-Druckers manuell. + /// + public void SetSimulationState(string printerName, bool ltsSensor, bool druckerklappe, bool keineEtiketten) + { + if (_monitors.TryGetValue(printerName, out var monitor) && monitor is SimulationMonitor sim) + { + sim.SetState(ltsSensor, druckerklappe, keineEtiketten); + } + } + + private IPrinterMonitor CreateMonitor(PrinterConfig config) + { + // Defensiv: alten korrupten Wert "System.Windows.Controls.ComboBoxItem: X" bereinigen + var type = config.Type ?? ""; + if (type.Contains(':')) + type = type.Split(':').Last().Trim(); + + return type.ToLowerInvariant() switch + { + "cabsquix" => new CabSquixMonitor(config), + "zebra" => new ZebraMonitor(config), + "honeywell" => new HoneywellMonitor(config), + "simulation" => new SimulationMonitor(config), + _ => throw new NotSupportedException($"Druckertyp '{config.Type}' wird nicht unterstützt.") + }; + } + + private async Task RunPollingLoop(PrinterConfig config, IPrinterMonitor monitor, CancellationToken ct) + { + var name = config.Name; + + while (!ct.IsCancellationRequested) + { + if (!monitor.IsConnected) + { + try + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {name}: Verbinde..."); + await monitor.ConnectAsync(ct); + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {name}: Verbunden."); + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {name}: Verbindung fehlgeschlagen – {ex.Message}"); + UpdateStatus(name, config.Type, online: false, error: $"Verbindungsfehler: {ex.Message}"); + await SafeDelay(ReconnectDelayMs, ct); + continue; + } + } + + try + { + var newState = await monitor.PollStateAsync(ct); + UpdateStatus(name, config.Type, online: true, state: newState); + _lastStates[name] = newState; + + await BroadcastIfChangedAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {name}: Polling-Fehler – {ex.Message}"); + UpdateStatus(name, config.Type, online: false, error: ex.Message); + _lastStates[name] = null; + + // Sofort aggregieren & senden damit der offline-State nicht im Puffer bleibt + await BroadcastIfChangedAsync(); + + try { await monitor.DisconnectAsync(); } + catch (Exception dex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {name}: Fehler beim Trennen – {dex.Message}"); } + await SafeDelay(ReconnectDelayMs, ct); + continue; + } + + await SafeDelay(PollingIntervalMs, ct); + } + } + + private async Task BroadcastIfChangedAsync() + { + string? payload; + + lock (_aggregationLock) + { + SimplePrinterState? aggregated = null; + foreach (var s in _lastStates.Values) + { + if (s == null) continue; + if (aggregated == null) + { + aggregated = s; + continue; + } + aggregated = new SimplePrinterState + { + LtsSensor = aggregated.LtsSensor || s.LtsSensor, + Druckerklappe = aggregated.Druckerklappe || s.Druckerklappe, + KeineEtiketten = aggregated.KeineEtiketten || s.KeineEtiketten + }; + } + + if (aggregated == null) + { + _lastAggregatedState = null; + return; + } + + if (_lastAggregatedState != null && aggregated.Equals(_lastAggregatedState)) + return; + + _lastAggregatedState = aggregated; + payload = aggregated.ToTcpString(); + } + + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Aggregiert: Zustandsänderung -> {payload}"); + + if (_tcpClient != null) + await _tcpClient.SendStateAsync(payload); + } + + private async Task PeriodicBroadcastLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await SafeDelay(HeartbeatIntervalMs, ct); + + string? payload; + lock (_aggregationLock) + { + if (_lastAggregatedState == null) continue; + payload = _lastAggregatedState.ToTcpString(); + } + + if (_tcpClient != null) + await _tcpClient.SendStateAsync(payload); + } + } + + private void UpdateStatus(string name, string type, bool online, + SimplePrinterState? state = null, string? error = null) + { + _statusCache[name] = new PrinterStatus + { + PrinterName = name, + PrinterType = type, + IsOnline = online, + LastUpdated = DateTime.Now, + ErrorMessage = error, + LtsSensor = state?.LtsSensor, + Druckerklappe = state?.Druckerklappe, + KeineEtiketten = state?.KeineEtiketten + }; + } + + private static async Task SafeDelay(int ms, CancellationToken ct) + { + try { await Task.Delay(ms, ct); } + catch (OperationCanceledException) { } + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + _cts?.Dispose(); + } +} diff --git a/Tcp/TcpPushClient.cs b/Tcp/TcpPushClient.cs new file mode 100644 index 0000000..8df0c6a --- /dev/null +++ b/Tcp/TcpPushClient.cs @@ -0,0 +1,137 @@ +using System.Net.Sockets; +using System.Text; + +namespace PrinterMonitor.Tcp; + +/// +/// Verbindet sich als TCP-Client auf localhost:12164 und sendet +/// den aggregierten Druckerzustand im VW-Protokoll (STX/ETX-Framing). +/// +/// Reconnect-Logik: Bei Verbindungsverlust wird automatisch alle 5 Sekunden +/// ein erneuter Verbindungsversuch unternommen. +/// +public class TcpPushClient : IAsyncDisposable +{ + private const byte Stx = 0x02; + private const byte Etx = 0x03; + private const int ReconnectDelayMs = 5000; + + private readonly string _host; + private readonly int _port; + + private TcpClient? _client; + private string? _lastPayload; + private CancellationTokenSource? _cts; + private Task? _reconnectTask; + + public bool IsConnected => _client?.Connected == true; + + public TcpPushClient(string host, int port) + { + _host = host; + _port = port; + } + + public void Start() + { + _cts = new CancellationTokenSource(); + _reconnectTask = KeepConnectedAsync(_cts.Token); + } + + /// + /// Sendet den Payload mit STX/ETX-Framing an den verbundenen Server. + /// Bei fehlender Verbindung wird der Payload verworfen (Reconnect läuft im Hintergrund). + /// + public async Task SendStateAsync(string payload) + { + _lastPayload = payload; + + // Lokale Referenz: verhindert NullRef bei gleichzeitigem DropClient() + var client = _client; + if (client?.Connected != true) return; + + try + { + var frame = BuildFrame(payload); + var stream = client.GetStream(); + await stream.WriteAsync(frame); + await stream.FlushAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] TCP-Client [{_host}:{_port}]: Sendefehler – {ex.Message}"); + DropClient(); + } + } + + private async Task KeepConnectedAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + if (_client?.Connected != true) + { + DropClient(); + TcpClient? newClient = null; + try + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] TCP-Client: Verbinde auf {_host}:{_port}..."); + newClient = new TcpClient(); + await newClient.ConnectAsync(_host, _port, ct); + _client = newClient; + newClient = null; // Ownership an _client übertragen + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] TCP-Client: Verbunden mit {_host}:{_port}"); + + // Letzten bekannten Zustand sofort senden + if (_lastPayload != null) + await SendStateAsync(_lastPayload); + } + catch (OperationCanceledException) { return; } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] TCP-Client: Verbindungsfehler – {ex.Message}"); + newClient?.Dispose(); // Bei Fehler vor Zuweisung aufräumen + DropClient(); + await SafeDelay(ReconnectDelayMs, ct); + continue; + } + } + + await SafeDelay(ReconnectDelayMs, ct); + } + } + + private void DropClient() + { + try { _client?.Dispose(); } catch { } + _client = null; + } + + private static byte[] BuildFrame(string payload) + { + var payloadBytes = Encoding.UTF8.GetBytes(payload); + var frame = new byte[payloadBytes.Length + 2]; + frame[0] = Stx; + payloadBytes.CopyTo(frame, 1); + frame[^1] = Etx; + return frame; + } + + private static async Task SafeDelay(int ms, CancellationToken ct) + { + try { await Task.Delay(ms, ct); } + catch (OperationCanceledException) { } + } + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_reconnectTask != null) + { + try { await _reconnectTask; } catch { } + } + DropClient(); + _lastPayload = null; + _cts?.Dispose(); + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] TCP-Client gestoppt."); + } +} diff --git a/ViewModels/DashboardViewModel.cs b/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..c2b2ea8 --- /dev/null +++ b/ViewModels/DashboardViewModel.cs @@ -0,0 +1,85 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Threading; +using PrinterMonitor.Services; + +namespace PrinterMonitor.ViewModels; + +/// +/// ViewModel für das Dashboard. +/// Implementiert IDisposable: DispatcherTimer wird beim Disposen gestoppt, +/// damit das ViewModel vom GC freigegeben werden kann (kein Timer-Leak). +/// +public class DashboardViewModel : ViewModelBase, IDisposable +{ + public ObservableCollection Printers { get; } = new(); + + private readonly PrinterService _printerService; + private readonly DispatcherTimer _timer; + private bool _disposed; + + public DashboardViewModel(PrinterService printerService) + { + _printerService = printerService; + + foreach (var name in _printerService.GetPrinterNames()) + { + var status = _printerService.GetStatus(name); + var vm = new PrinterStatusViewModel(name, status?.PrinterType ?? ""); + vm.OnSimulationStateChanged = OnSimulationStateChanged; + if (status != null) + vm.Update(status); + Printers.Add(vm); + } + + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _timer.Tick += (s, e) => RefreshAll(); + _timer.Start(); + } + + private void OnSimulationStateChanged(string name, bool lts, bool klappe, bool keineEtiketten) + => _printerService.SetSimulationState(name, lts, klappe, keineEtiketten); + + private void RefreshAll() + { + var activeNames = _printerService.GetPrinterNames(); + var activeSet = new HashSet(activeNames, StringComparer.OrdinalIgnoreCase); + + // Entfernte Drucker aus Collection löschen + for (int i = Printers.Count - 1; i >= 0; i--) + { + if (!activeSet.Contains(Printers[i].Name)) + Printers.RemoveAt(i); + } + + // Bestehende aktualisieren + var existingSet = new HashSet(Printers.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + foreach (var vm in Printers) + { + var status = _printerService.GetStatus(vm.Name); + if (status != null) + vm.Update(status); + } + + // Neue Drucker hinzufügen + foreach (var name in activeNames) + { + if (!existingSet.Contains(name)) + { + var status = _printerService.GetStatus(name); + var vm = new PrinterStatusViewModel(name, status?.PrinterType ?? ""); + vm.OnSimulationStateChanged = OnSimulationStateChanged; + if (status != null) + vm.Update(status); + Printers.Add(vm); + } + } + } + + public void Dispose() + { + if (_disposed) return; + _timer.Stop(); + _disposed = true; + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..436ccbd --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using PrinterMonitor.Configuration; +using PrinterMonitor.Services; + +namespace PrinterMonitor.ViewModels; + +public class MainViewModel : ViewModelBase +{ + public DashboardViewModel Dashboard { get; } + public SettingsViewModel Settings { get; } + public string WindowTitle { get; } + + public static string VersionString + => Assembly.GetExecutingAssembly().GetName().Version is { } v + ? $"{v.Major}.{v.Minor}.{v.Build}" + : "?"; + + public MainViewModel(PrinterService printerService, AppSettings settings) + { + Dashboard = new DashboardViewModel(printerService); + Settings = new SettingsViewModel(settings, printerService.RestartAsync); + WindowTitle = $"DS Soft-LTS v{VersionString}"; + } +} diff --git a/ViewModels/PrinterStatusViewModel.cs b/ViewModels/PrinterStatusViewModel.cs new file mode 100644 index 0000000..cd9cc30 --- /dev/null +++ b/ViewModels/PrinterStatusViewModel.cs @@ -0,0 +1,112 @@ +using System.Windows.Media; +using PrinterMonitor.Models; + +namespace PrinterMonitor.ViewModels; + +public class PrinterStatusViewModel : ViewModelBase +{ + // Statische, eingefrorene Pinsel: einmalig allokiert und thread-safe via Freeze(). + // new SolidColorBrush(...) pro Update()-Aufruf entfällt komplett. + private static readonly SolidColorBrush BrushOk = MakeFrozenBrush(144, 238, 144); + private static readonly SolidColorBrush BrushWarning = MakeFrozenBrush(255, 215, 0); + private static readonly SolidColorBrush BrushError = MakeFrozenBrush(255, 160, 160); + + private static SolidColorBrush MakeFrozenBrush(byte r, byte g, byte b) + { + var brush = new SolidColorBrush(Color.FromRgb(r, g, b)); + brush.Freeze(); + return brush; + } + + private string _name = ""; + private string _type = ""; + private bool _isOnline; + private string _ltsText = "-"; + private string _klappeText = "-"; + private string _etikettenText = "-"; + private bool _ltsSensor; + private bool _druckerklappe; + private bool _keineEtiketten; + private SolidColorBrush _rowBrush = BrushOk; + private string _errorMessage = ""; + + /// Wird aufgerufen wenn ein Sensor-Wert per Checkbox geändert wird. + public Action? OnSimulationStateChanged { get; set; } + + public string Name { get => _name; set => SetProperty(ref _name, value); } + public string Type { get => _type; set => SetProperty(ref _type, value); } + public bool IsOnline { get => _isOnline; set => SetProperty(ref _isOnline, value); } + public string LtsText { get => _ltsText; set => SetProperty(ref _ltsText, value); } + public string KlappeText { get => _klappeText; set => SetProperty(ref _klappeText, value); } + public string EtikettenText { get => _etikettenText; set => SetProperty(ref _etikettenText, value); } + public SolidColorBrush RowBrush { get => _rowBrush; set => SetProperty(ref _rowBrush, value); } + public string ErrorMessage { get => _errorMessage; set => SetProperty(ref _errorMessage, value); } + + public bool IsSimulation => string.Equals(Type, "Simulation", StringComparison.OrdinalIgnoreCase); + public bool IsNotSimulation => !IsSimulation; + + public bool LtsSensor + { + get => _ltsSensor; + set + { + if (SetProperty(ref _ltsSensor, value) && IsSimulation) + OnSimulationStateChanged?.Invoke(Name, value, _druckerklappe, _keineEtiketten); + } + } + + public bool Druckerklappe + { + get => _druckerklappe; + set + { + if (SetProperty(ref _druckerklappe, value) && IsSimulation) + OnSimulationStateChanged?.Invoke(Name, _ltsSensor, value, _keineEtiketten); + } + } + + public bool KeineEtiketten + { + get => _keineEtiketten; + set + { + if (SetProperty(ref _keineEtiketten, value) && IsSimulation) + OnSimulationStateChanged?.Invoke(Name, _ltsSensor, _druckerklappe, value); + } + } + + public PrinterStatusViewModel(string name, string type) + { + Name = name; + Type = type; + } + + public void Update(PrinterStatus status) + { + IsOnline = status.IsOnline; + ErrorMessage = status.ErrorMessage ?? ""; + + // Bool-Werte für Checkboxen (ohne Callback-Trigger via interne Felder setzen) + _ltsSensor = status.LtsSensor ?? false; + _druckerklappe = status.Druckerklappe ?? false; + _keineEtiketten = status.KeineEtiketten ?? false; + OnPropertyChanged(nameof(LtsSensor)); + OnPropertyChanged(nameof(Druckerklappe)); + OnPropertyChanged(nameof(KeineEtiketten)); + + LtsText = FormatBool(status.LtsSensor, "Belegt", "Frei"); + KlappeText = FormatBool(status.Druckerklappe, "Offen", "Geschlossen"); + EtikettenText = FormatBool(status.KeineEtiketten, "Leer", "OK"); + + // Statische Pinsel wiederverwenden – keine Heap-Allokation pro Update + if (!status.IsOnline) + RowBrush = BrushError; + else if (status.Druckerklappe == true || status.KeineEtiketten == true) + RowBrush = BrushWarning; + else + RowBrush = BrushOk; + } + + private static string FormatBool(bool? value, string trueText, string falseText) + => value.HasValue ? (value.Value ? trueText : falseText) : "-"; +} diff --git a/ViewModels/RelayCommand.cs b/ViewModels/RelayCommand.cs new file mode 100644 index 0000000..96c34e3 --- /dev/null +++ b/ViewModels/RelayCommand.cs @@ -0,0 +1,60 @@ +using System.Windows.Input; + +namespace PrinterMonitor.ViewModels; + +public class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; + public void Execute(object? parameter) => _execute(); +} + +/// +/// Führt ein async-Lambda als ICommand aus. +/// Während der Ausführung ist CanExecute=false (verhindert Doppelklick). +/// +public class AsyncRelayCommand : ICommand +{ + private readonly Func _execute; + private bool _isExecuting; + + public AsyncRelayCommand(Func execute) + { + _execute = execute; + } + + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public bool CanExecute(object? parameter) => !_isExecuting; + + public async void Execute(object? parameter) + { + _isExecuting = true; + CommandManager.InvalidateRequerySuggested(); + try { await _execute(); } + finally + { + _isExecuting = false; + CommandManager.InvalidateRequerySuggested(); + } + } +} + diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..9c2308a --- /dev/null +++ b/ViewModels/SettingsViewModel.cs @@ -0,0 +1,187 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Windows; +using System.Windows.Input; +using PrinterMonitor.Configuration; + +namespace PrinterMonitor.ViewModels; + +public class SettingsViewModel : ViewModelBase +{ + private readonly AppSettings _settings; + public ObservableCollection Printers { get; } + + // Globale Einstellungen + private int _tcpTargetPort; + public int TcpTargetPort { get => _tcpTargetPort; set => SetProperty(ref _tcpTargetPort, value); } + + private PrinterConfig? _selectedPrinter; + public PrinterConfig? SelectedPrinter + { + get => _selectedPrinter; + set + { + if (_selectedPrinter != null) + ApplyEdits(); + + if (SetProperty(ref _selectedPrinter, value)) + { + LoadEdits(); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(IsNetworkPrinter)); + } + } + } + + public bool HasSelection => _selectedPrinter != null; + public bool IsNetworkPrinter => _selectedPrinter != null && + !string.Equals(_selectedPrinter.Type, "Simulation", StringComparison.OrdinalIgnoreCase); + + // Edit-Felder + private string _editName = ""; + private string _editType = "CabSquix"; + private string _editHost = ""; + private int _editPort; + private bool _editEnabled = true; + + public string EditName { get => _editName; set => SetProperty(ref _editName, value); } + + public string EditType + { + get => _editType; + set + { + if (SetProperty(ref _editType, value)) + OnPropertyChanged(nameof(IsNetworkPrinter)); + } + } + + public string EditHost { get => _editHost; set => SetProperty(ref _editHost, value); } + public int EditPort { get => _editPort; set => SetProperty(ref _editPort, value); } + public bool EditEnabled { get => _editEnabled; set => SetProperty(ref _editEnabled, value); } + + public IReadOnlyList PrinterTypes { get; } = + new[] { "CabSquix", "Zebra", "Honeywell", "Simulation" }; + + public ICommand AddCommand { get; } + public ICommand RemoveCommand { get; } + public ICommand SaveCommand { get; } + + private readonly Func _onSaved; + + public SettingsViewModel(AppSettings settings, Func onSaved) + { + _settings = settings; + _onSaved = onSaved; + Printers = new ObservableCollection(settings.Printers); + TcpTargetPort = settings.TcpTargetPort; + + AddCommand = new RelayCommand(AddPrinter); + RemoveCommand = new RelayCommand(RemovePrinter, () => HasSelection); + SaveCommand = new AsyncRelayCommand(SaveAsync); + } + + private void AddPrinter() + { + var newPrinter = new PrinterConfig + { + Name = "Neuer Drucker", + Type = "CabSquix", + Host = "", + Port = 4840, + Enabled = true + }; + Printers.Add(newPrinter); + SelectedPrinter = newPrinter; + } + + private void RemovePrinter() + { + if (_selectedPrinter == null) return; + var idx = Printers.IndexOf(_selectedPrinter); + Printers.Remove(_selectedPrinter); + _selectedPrinter = null; + LoadEdits(); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(SelectedPrinter)); + + if (Printers.Count > 0) + SelectedPrinter = Printers[Math.Min(idx, Printers.Count - 1)]; + } + + private async Task SaveAsync() + { + if (_selectedPrinter != null) + ApplyEdits(); + + try + { + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + JsonNode? root = null; + if (File.Exists(configPath)) + { + var existing = await File.ReadAllTextAsync(configPath); + root = JsonNode.Parse(existing); + } + root ??= new JsonObject(); + + root["tcpTargetPort"] = TcpTargetPort; + root["printers"] = JsonSerializer.SerializeToNode(Printers.ToList(), jsonOptions); + + await File.WriteAllTextAsync(configPath, + root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + _settings.TcpTargetPort = TcpTargetPort; + _settings.Printers = Printers.ToList(); + + await _onSaved(); + + MessageBox.Show("Einstellungen gespeichert. Monitoring wurde neu gestartet.", + "Gespeichert", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Fehler beim Speichern:\n{ex.Message}", + "Fehler", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void LoadEdits() + { + if (_selectedPrinter == null) + { + EditName = ""; + EditType = "CabSquix"; + EditHost = ""; + EditPort = 0; + EditEnabled = true; + return; + } + + EditName = _selectedPrinter.Name; + EditType = _selectedPrinter.Type; + EditHost = _selectedPrinter.Host; + EditPort = _selectedPrinter.Port; + EditEnabled = _selectedPrinter.Enabled; + } + + private void ApplyEdits() + { + if (_selectedPrinter == null) return; + + _selectedPrinter.Name = EditName; + _selectedPrinter.Type = EditType; + _selectedPrinter.Host = EditHost; + _selectedPrinter.Port = EditPort; + _selectedPrinter.Enabled = EditEnabled; + } +} diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..8c8123b --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace PrinterMonitor.ViewModels; + +public abstract class ViewModelBase : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } +} diff --git a/Views/DashboardView.xaml b/Views/DashboardView.xaml new file mode 100644 index 0000000..82054c0 --- /dev/null +++ b/Views/DashboardView.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/DashboardView.xaml.cs b/Views/DashboardView.xaml.cs new file mode 100644 index 0000000..17570e0 --- /dev/null +++ b/Views/DashboardView.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace PrinterMonitor.Views; + +public partial class DashboardView : UserControl +{ + public DashboardView() + { + InitializeComponent(); + } +} diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml new file mode 100644 index 0000000..772e8c9 --- /dev/null +++ b/Views/MainWindow.xaml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/Views/MainWindow.xaml.cs b/Views/MainWindow.xaml.cs new file mode 100644 index 0000000..150a1e5 --- /dev/null +++ b/Views/MainWindow.xaml.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; +using System.Windows; + +namespace PrinterMonitor.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } + + protected override void OnClosing(CancelEventArgs e) + { + // Fenster nur verstecken, nicht schließen — App läuft im Tray weiter + e.Cancel = true; + Hide(); + } +} diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml new file mode 100644 index 0000000..c81e74d --- /dev/null +++ b/Views/SettingsView.xaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + +