commit 5a4bacd5ca043c6de55f090aecd1a5128fe9abbd Author: m.urhan Date: Sun Apr 12 17:39:45 2026 +0200 Initial commit 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 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 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 0000000..be13707 Binary files /dev/null and b/Resources/app.ico differ diff --git a/Resources/printer.ico b/Resources/printer.ico new file mode 100644 index 0000000..497e09e Binary files /dev/null and b/Resources/printer.ico differ diff --git a/Services/PrinterService.cs b/Services/PrinterService.cs new file mode 100644 index 0000000..43ab8c4 --- /dev/null +++ b/Services/PrinterService.cs @@ -0,0 +1,276 @@ +using System.Collections.Concurrent; +using PrinterMonitor.Configuration; +using PrinterMonitor.Interfaces; +using PrinterMonitor.Models; +using PrinterMonitor.Monitors; +using PrinterMonitor.Tcp; + +namespace PrinterMonitor.Services; + +/// +/// 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 @@ + + + + + + + + + + + + + + + + + + + + + +