feat: source code

Quellcode durchsuchen
Dieser Commit ist enthalten in:
a
2026-04-12 17:22:31 +02:00
Ursprung 971980452f
Commit 1045541f03
37 geänderte Dateien mit 2449 neuen und 0 gelöschten Zeilen

11
.claude/launch.json Normale Datei
Datei anzeigen

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "PrinterMonitor",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run"],
"port": 5000
}
]
}

32
.claude/settings.local.json Normale Datei
Datei anzeigen

@@ -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)"
]
}
}

9
App.xaml Normale Datei
Datei anzeigen

@@ -0,0 +1,9 @@
<Application x:Class="PrinterMonitor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:PrinterMonitor"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<local:BoolToVisibilityConverter x:Key="BoolToVis"/>
</Application.Resources>
</Application>

197
App.xaml.cs Normale Datei
Datei anzeigen

@@ -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<AppSettings>(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);
}
}

15
BoolToVisibilityConverter.cs Normale Datei
Datei anzeigen

@@ -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;
}

67
CLAUDE.md Normale Datei
Datei anzeigen

@@ -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()`

34
Configuration/AppSettings.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,34 @@
namespace PrinterMonitor.Configuration;
public class AppSettings
{
public List<PrinterConfig> Printers { get; set; } = new();
/// <summary>TCP-Port, auf den der Client verbindet (Default: 12164).</summary>
public int TcpTargetPort { get; set; } = 12164;
/// <summary>
/// Prüft die Konfiguration und gibt eine Liste von Fehlern zurück (leer = gültig).
/// </summary>
public List<string> Validate()
{
var errors = new List<string>();
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;
}
}

Datei anzeigen

@@ -0,0 +1,39 @@
namespace PrinterMonitor.Configuration;
public class PrinterConfig
{
private static readonly HashSet<string> 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;
/// <summary>
/// Prüft die Konfiguration und gibt eine Liste von Fehlern zurück (leer = gültig).
/// </summary>
public List<string> Validate()
{
var errors = new List<string>();
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;
}
}

BIN
DATA-SCALES Logo 2017.png Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 33 KiB

Datei anzeigen

@@ -0,0 +1,17 @@
using PrinterMonitor.Models;
namespace PrinterMonitor.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IPrinterMonitor : IAsyncDisposable
{
string PrinterName { get; }
bool IsConnected { get; }
Task ConnectAsync(CancellationToken ct = default);
Task DisconnectAsync();
Task<SimplePrinterState> PollStateAsync(CancellationToken ct = default);
}

Datei anzeigen

@@ -0,0 +1,38 @@
using PrinterMonitor.Models;
namespace PrinterMonitor.Models;
/// <summary>
/// Berechnet den aggregierten (ODER-verknüpften) Druckerzustand
/// über alle überwachten Drucker hinweg.
/// </summary>
public static class AggregatedPrinterState
{
/// <summary>
/// Gibt einen <see cref="SimplePrinterState"/> zurück, dessen Felder
/// jeweils das logische ODER aller übergebenen Einzelzustände sind.
/// Gibt null zurück wenn keine Zustände übergeben wurden.
/// </summary>
public static SimplePrinterState? Aggregate(IEnumerable<SimplePrinterState> 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;
}
}

19
Models/PrinterStatus.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,19 @@
namespace PrinterMonitor.Models;
/// <summary>
/// Vereinfachter Druckerstatus für die GUI-Anzeige.
/// Kombiniert die drei überwachten Zustände mit Verbindungs-Metadaten.
/// </summary>
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; }
}

36
Models/SimplePrinterState.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,36 @@
namespace PrinterMonitor.Models;
/// <summary>
/// Die drei überwachten Druckerzustände.
/// Wird bei jedem Poll-Zyklus erzeugt und mit dem vorherigen Zustand verglichen.
/// </summary>
public class SimplePrinterState : IEquatable<SimplePrinterState>
{
/// <summary>LTS Sensor: true = Etikett steht an / Sensor belegt</summary>
public bool LtsSensor { get; init; }
/// <summary>Druckerklappe: true = Druckkopf offen</summary>
public bool Druckerklappe { get; init; }
/// <summary>Keine Etiketten: true = Papier leer/niedrig</summary>
public bool KeineEtiketten { get; init; }
/// <summary>
/// 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.
/// </summary>
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);
}

58
Monitors/CabSquixMonitor.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,58 @@
using PrinterMonitor.Configuration;
using PrinterMonitor.Interfaces;
using PrinterMonitor.Models;
namespace PrinterMonitor.Monitors;
/// <summary>
/// IPrinterMonitor-Implementierung für CAB Squix Drucker via OPC UA.
/// Liest nur die drei überwachten Zustände.
/// </summary>
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<SimplePrinterState> 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();
}
}

184
Monitors/HoneywellMonitor.cs Normale Datei
Datei anzeigen

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<SimplePrinterState> PollStateAsync(CancellationToken ct = default)
{
if (_isDisconnected || _tcpClient == null || !_tcpClient.Connected)
throw new IOException("Verbindung zum Honeywell-Drucker getrennt");
return Task.FromResult(_lastState);
}
/// <summary>
/// Liest Statuszeilen vom Drucker im Hintergrund.
/// Format: LTS:0;HEAD:0;MEDIA:0
/// </summary>
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) { }
}
/// <summary>
/// Parst eine Honeywell-Statuszeile: LTS:0;HEAD:0;MEDIA:0
/// Gibt null zurück wenn das Format ungültig ist.
/// </summary>
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();
}
}

Datei anzeigen

@@ -0,0 +1,45 @@
using PrinterMonitor.Configuration;
using PrinterMonitor.Interfaces;
using PrinterMonitor.Models;
namespace PrinterMonitor.Monitors;
/// <summary>
/// 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.
/// </summary>
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<SimplePrinterState> PollStateAsync(CancellationToken ct = default)
=> Task.FromResult(_state);
public ValueTask DisposeAsync()
=> ValueTask.CompletedTask;
}

204
Monitors/SquixOpcUaClient.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,204 @@
using System.IO;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
namespace PrinterMonitor.Monitors;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
/// <summary>
/// Liest LTS-Sensor, Druckkopf-Klappe und Papier-Leer in einem einzigen OPC UA Batch-Call.
/// </summary>
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<object?[]> 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;
}
}

153
Monitors/ZebraMonitor.cs Normale Datei
Datei anzeigen

@@ -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;
/// <summary>
/// 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
/// </summary>
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<SimplePrinterState> 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)
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<string> 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();
}
}

76
PrinterMonitor.csproj Normale Datei
Datei anzeigen

@@ -0,0 +1,76 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>PrinterMonitor</RootNamespace>
<AssemblyName>PrinterMonitor</AssemblyName>
<Version>1.0.2</Version>
<ApplicationIcon>Resources\app.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- WinForms global using entfernen um Konflikt mit WPF-Namespaces zu vermeiden -->
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
</ItemGroup>
<!-- OPC UA Test Ordner aus Kompilierung ausschließen (eigenes separates Projekt) -->
<ItemGroup>
<Compile Remove="OPC UA Test\**" />
<EmbeddedResource Remove="OPC UA Test\**" />
<None Remove="OPC UA Test\**" />
</ItemGroup>
<!-- OPC UA Assemblies aus lokalem lib/ Ordner (keine NuGet-Verbindung nötig) -->
<ItemGroup>
<Reference Include="Opc.Ua.Core">
<HintPath>$(MSBuildThisFileDirectory)lib\Opc.Ua.Core.dll</HintPath>
</Reference>
<Reference Include="Opc.Ua.Client">
<HintPath>$(MSBuildThisFileDirectory)lib\Opc.Ua.Client.dll</HintPath>
</Reference>
<Reference Include="Opc.Ua.Configuration">
<HintPath>$(MSBuildThisFileDirectory)lib\Opc.Ua.Configuration.dll</HintPath>
</Reference>
<Reference Include="Opc.Ua.Types">
<HintPath>$(MSBuildThisFileDirectory)lib\Opc.Ua.Types.dll</HintPath>
</Reference>
<Reference Include="Opc.Ua.Security.Certificates">
<HintPath>$(MSBuildThisFileDirectory)lib\Opc.Ua.Security.Certificates.dll</HintPath>
</Reference>
<Reference Include="BitFaster.Caching">
<HintPath>$(MSBuildThisFileDirectory)lib\BitFaster.Caching.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(MSBuildThisFileDirectory)lib\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System.Formats.Asn1">
<HintPath>$(MSBuildThisFileDirectory)lib\System.Formats.Asn1.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.DiagnosticSource">
<HintPath>$(MSBuildThisFileDirectory)lib\System.Diagnostics.DiagnosticSource.dll</HintPath>
</Reference>
<Reference Include="System.Collections.Immutable">
<HintPath>$(MSBuildThisFileDirectory)lib\System.Collections.Immutable.dll</HintPath>
</Reference>
<Reference Include="System.IO.Pipelines">
<HintPath>$(MSBuildThisFileDirectory)lib\System.IO.Pipelines.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\app.ico" />
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

24
PrinterMonitor.sln Normale Datei
Datei anzeigen

@@ -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

BIN
Resources/app.ico Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 8.5 KiB

BIN
Resources/printer.ico Normale Datei

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 1.1 KiB

276
Services/PrinterService.cs Normale Datei
Datei anzeigen

@@ -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;
/// <summary>
/// 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)
/// </summary>
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<string, IPrinterMonitor> _monitors = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, PrinterStatus> _statusCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, SimplePrinterState?> _lastStates = new(StringComparer.OrdinalIgnoreCase);
private TcpPushClient? _tcpClient;
private SimplePrinterState? _lastAggregatedState;
private readonly object _aggregationLock = new();
private CancellationTokenSource? _cts;
private readonly List<Task> _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;
}
}
/// <summary>
/// Startet das Monitoring mit der aktuellen Konfiguration neu.
/// Wird nach dem Speichern der Einstellungen aufgerufen.
/// </summary>
public async Task RestartAsync()
{
await StopAsync();
await StartAsync();
}
public IReadOnlyList<string> GetPrinterNames()
=> _statusCache.Keys.ToList().AsReadOnly();
public PrinterStatus? GetStatus(string printerName)
{
_statusCache.TryGetValue(printerName, out var status);
return status;
}
/// <summary>
/// Setzt den Zustand eines Simulation-Druckers manuell.
/// </summary>
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();
}
}

137
Tcp/TcpPushClient.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,137 @@
using System.Net.Sockets;
using System.Text;
namespace PrinterMonitor.Tcp;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Sendet den Payload mit STX/ETX-Framing an den verbundenen Server.
/// Bei fehlender Verbindung wird der Payload verworfen (Reconnect läuft im Hintergrund).
/// </summary>
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.");
}
}

Datei anzeigen

@@ -0,0 +1,85 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Threading;
using PrinterMonitor.Services;
namespace PrinterMonitor.ViewModels;
/// <summary>
/// ViewModel für das Dashboard.
/// Implementiert IDisposable: DispatcherTimer wird beim Disposen gestoppt,
/// damit das ViewModel vom GC freigegeben werden kann (kein Timer-Leak).
/// </summary>
public class DashboardViewModel : ViewModelBase, IDisposable
{
public ObservableCollection<PrinterStatusViewModel> 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<string>(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<string>(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;
}
}

24
ViewModels/MainViewModel.cs Normale Datei
Datei anzeigen

@@ -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}";
}
}

Datei anzeigen

@@ -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 = "";
/// <summary>Wird aufgerufen wenn ein Sensor-Wert per Checkbox geändert wird.</summary>
public Action<string, bool, bool, bool>? 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) : "-";
}

60
ViewModels/RelayCommand.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,60 @@
using System.Windows.Input;
namespace PrinterMonitor.ViewModels;
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? 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();
}
/// <summary>
/// Führt ein async-Lambda als ICommand aus.
/// Während der Ausführung ist CanExecute=false (verhindert Doppelklick).
/// </summary>
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> 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();
}
}
}

Datei anzeigen

@@ -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<PrinterConfig> 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<string> PrinterTypes { get; } =
new[] { "CabSquix", "Zebra", "Honeywell", "Simulation" };
public ICommand AddCommand { get; }
public ICommand RemoveCommand { get; }
public ICommand SaveCommand { get; }
private readonly Func<Task> _onSaved;
public SettingsViewModel(AppSettings settings, Func<Task> onSaved)
{
_settings = settings;
_onSaved = onSaved;
Printers = new ObservableCollection<PrinterConfig>(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;
}
}

23
ViewModels/ViewModelBase.cs Normale Datei
Datei anzeigen

@@ -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<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

81
Views/DashboardView.xaml Normale Datei
Datei anzeigen

@@ -0,0 +1,81 @@
<UserControl x:Class="PrinterMonitor.Views.DashboardView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<DockPanel Margin="10">
<TextBlock DockPanel.Dock="Top"
Text="Drucker-Status (Live)"
FontWeight="Bold"
FontSize="16"
Margin="0,0,0,10"/>
<DataGrid ItemsSource="{Binding Printers}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
SelectionMode="Single"
HeadersVisibility="Column">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="{Binding RowBrush}"/>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="150" IsReadOnly="True"/>
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="90" IsReadOnly="True"/>
<DataGridTextColumn Header="Online" Binding="{Binding IsOnline}" Width="60" IsReadOnly="True"/>
<!-- LTS Sensor: Checkbox für Simulation, Text für echte Drucker -->
<DataGridTemplateColumn Header="LTS Sensor" Width="90">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid>
<CheckBox IsChecked="{Binding LtsSensor, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding IsSimulation, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding LtsText}" VerticalAlignment="Center" Padding="2,0"
Visibility="{Binding IsNotSimulation, Converter={StaticResource BoolToVis}}"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Druckerklappe -->
<DataGridTemplateColumn Header="Klappe" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid>
<CheckBox IsChecked="{Binding Druckerklappe, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding IsSimulation, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding KlappeText}" VerticalAlignment="Center" Padding="2,0"
Visibility="{Binding IsNotSimulation, Converter={StaticResource BoolToVis}}"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Keine Etiketten -->
<DataGridTemplateColumn Header="Etiketten" Width="90">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid>
<CheckBox IsChecked="{Binding KeineEtiketten, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding IsSimulation, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding EtikettenText}" VerticalAlignment="Center" Padding="2,0"
Visibility="{Binding IsNotSimulation, Converter={StaticResource BoolToVis}}"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Fehler" Binding="{Binding ErrorMessage}" Width="*" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

11
Views/DashboardView.xaml.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace PrinterMonitor.Views;
public partial class DashboardView : UserControl
{
public DashboardView()
{
InitializeComponent();
}
}

16
Views/MainWindow.xaml Normale Datei
Datei anzeigen

@@ -0,0 +1,16 @@
<Window x:Class="PrinterMonitor.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:PrinterMonitor.Views"
Title="{Binding WindowTitle}" Width="900" Height="500"
Icon="pack://application:,,,/Resources/app.ico"
WindowStartupLocation="CenterScreen">
<TabControl Margin="8">
<TabItem Header="Status">
<views:DashboardView DataContext="{Binding Dashboard}"/>
</TabItem>
<TabItem Header="Einstellungen">
<views:SettingsView DataContext="{Binding Settings}"/>
</TabItem>
</TabControl>
</Window>

19
Views/MainWindow.xaml.cs Normale Datei
Datei anzeigen

@@ -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();
}
}

130
Views/SettingsView.xaml Normale Datei
Datei anzeigen

@@ -0,0 +1,130 @@
<UserControl x:Class="PrinterMonitor.Views.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<DockPanel Margin="10">
<TextBlock DockPanel.Dock="Top"
Text="Drucker-Einstellungen"
FontWeight="Bold"
FontSize="16"
Margin="0,0,0,10"/>
<!-- Globale Einstellungen -->
<Border DockPanel.Dock="Top"
Background="#F5F5F5"
BorderBrush="#DDDDDD"
BorderThickness="1"
CornerRadius="4"
Padding="10"
Margin="0,0,0,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="TCP Ziel-Port:" VerticalAlignment="Center"/>
<TextBox Grid.Column="1"
Text="{Binding TcpTargetPort, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2"
Text="(Standard: 12164, Verbindung zu localhost)"
Foreground="Gray"
FontStyle="Italic"
VerticalAlignment="Center"
Margin="8,0,0,0"/>
</Grid>
</Border>
<!-- Buttons unten -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,10,0,0">
<Button Content="Hinzufügen" Command="{Binding AddCommand}" Width="100" Margin="0,0,8,0"/>
<Button Content="Entfernen" Command="{Binding RemoveCommand}" Width="100" Margin="0,0,8,0"/>
<Button Content="Speichern" Command="{Binding SaveCommand}" Width="100" FontWeight="Bold"/>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Drucker-Liste links -->
<ListBox Grid.Column="0"
ItemsSource="{Binding Printers}"
SelectedItem="{Binding SelectedPrinter}"
DisplayMemberPath="Name"/>
<!-- Formular rechts -->
<Grid Grid.Column="2" IsEnabled="{Binding HasSelection}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name:" VerticalAlignment="Center" Margin="0,0,0,6"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding EditName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Typ:" VerticalAlignment="Center" Margin="0,0,0,6"/>
<ComboBox Grid.Row="1" Grid.Column="1"
ItemsSource="{Binding PrinterTypes}"
SelectedItem="{Binding EditType}"
Margin="0,0,0,6"/>
<!-- Host und Port: nur für echte Netzwerk-Drucker -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Host:"
VerticalAlignment="Center" Margin="0,0,0,6"
Visibility="{Binding IsNetworkPrinter, Converter={StaticResource BoolToVis}}"/>
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding EditHost, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,6"
Visibility="{Binding IsNetworkPrinter, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Row="3" Grid.Column="0"
Text="Port:"
VerticalAlignment="Center" Margin="0,0,0,6"
Visibility="{Binding IsNetworkPrinter, Converter={StaticResource BoolToVis}}"/>
<TextBox Grid.Row="3" Grid.Column="1"
Text="{Binding EditPort, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,6"
Visibility="{Binding IsNetworkPrinter, Converter={StaticResource BoolToVis}}"/>
<!-- Hinweis für Simulation -->
<TextBlock Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
Text="Kein Netzwerk – Zustände werden manuell im Dashboard gesetzt."
Foreground="#888"
FontStyle="Italic"
TextWrapping="Wrap"
Margin="0,0,0,6">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsNetworkPrinter}" Value="False">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Aktiviert:" VerticalAlignment="Center" Margin="0,0,0,6"/>
<CheckBox Grid.Row="4" Grid.Column="1" IsChecked="{Binding EditEnabled}" VerticalAlignment="Center" Margin="0,0,0,6"/>
<TextBlock Grid.Row="5" Grid.ColumnSpan="2"
Text="Änderungen werden nach Neustart wirksam."
Foreground="Gray" FontStyle="Italic" Margin="0,10,0,0"/>
</Grid>
</Grid>
</DockPanel>
</UserControl>

11
Views/SettingsView.xaml.cs Normale Datei
Datei anzeigen

@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace PrinterMonitor.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}

19
appsettings.json Normale Datei
Datei anzeigen

@@ -0,0 +1,19 @@
{
"tcpTargetPort": 12164,
"printers": [
{
"name": "Squix-Lager1",
"type": "CabSquix",
"host": "192.168.170.107",
"port": 4840,
"enabled": true
},
{
"name": "Simulator",
"type": "Simulation",
"host": "",
"port": 0,
"enabled": true
}
]
}