Initial commit

Quellcode durchsuchen
Dieser Commit ist enthalten in:
2026-04-12 17:39:45 +02:00
Commit 5a4bacd5ca
55 geänderte Dateien mit 2491 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)"
]
}
}

42
.gitignore vendored Normale Datei
Datei anzeigen

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

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
}
]
}

BIN
lib/BitFaster.Caching.dll Normale Datei

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

BIN
lib/Newtonsoft.Json.dll Normale Datei

Binäre Datei nicht angezeigt.

BIN
lib/Opc.Ua.Client.dll Normale Datei

Binäre Datei nicht angezeigt.

BIN
lib/Opc.Ua.Configuration.dll Normale Datei

Binäre Datei nicht angezeigt.

BIN
lib/Opc.Ua.Core.dll Normale Datei

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

BIN
lib/Opc.Ua.Types.dll Normale Datei

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

BIN
lib/System.Formats.Asn1.dll Normale Datei

Binäre Datei nicht angezeigt.

BIN
lib/System.IO.Pipelines.dll Normale Datei

Binäre Datei nicht angezeigt.