feat: source code
Dieser Commit ist enthalten in:
11
.claude/launch.json
Normale Datei
11
.claude/launch.json
Normale Datei
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "PrinterMonitor",
|
||||
"runtimeExecutable": "dotnet",
|
||||
"runtimeArgs": ["run"],
|
||||
"port": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
32
.claude/settings.local.json
Normale Datei
32
.claude/settings.local.json
Normale Datei
@@ -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
9
App.xaml
Normale Datei
@@ -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
197
App.xaml.cs
Normale Datei
@@ -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
15
BoolToVisibilityConverter.cs
Normale Datei
@@ -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
67
CLAUDE.md
Normale Datei
@@ -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
34
Configuration/AppSettings.cs
Normale Datei
@@ -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;
|
||||
}
|
||||
}
|
||||
39
Configuration/PrinterConfig.cs
Normale Datei
39
Configuration/PrinterConfig.cs
Normale Datei
@@ -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
DATA-SCALES Logo 2017.png
Normale Datei
Binäre Datei nicht angezeigt.
|
Nachher Breite: | Höhe: | Größe: 33 KiB |
17
Interfaces/IPrinterMonitor.cs
Normale Datei
17
Interfaces/IPrinterMonitor.cs
Normale Datei
@@ -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);
|
||||
}
|
||||
38
Models/AggregatedPrinterState.cs
Normale Datei
38
Models/AggregatedPrinterState.cs
Normale Datei
@@ -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
19
Models/PrinterStatus.cs
Normale Datei
@@ -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
36
Models/SimplePrinterState.cs
Normale Datei
@@ -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
58
Monitors/CabSquixMonitor.cs
Normale Datei
@@ -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
184
Monitors/HoneywellMonitor.cs
Normale Datei
@@ -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();
|
||||
}
|
||||
}
|
||||
45
Monitors/SimulationMonitor.cs
Normale Datei
45
Monitors/SimulationMonitor.cs
Normale Datei
@@ -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
204
Monitors/SquixOpcUaClient.cs
Normale Datei
@@ -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
153
Monitors/ZebraMonitor.cs
Normale Datei
@@ -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
76
PrinterMonitor.csproj
Normale Datei
@@ -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
24
PrinterMonitor.sln
Normale Datei
@@ -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
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
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
276
Services/PrinterService.cs
Normale Datei
@@ -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
137
Tcp/TcpPushClient.cs
Normale Datei
@@ -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.");
|
||||
}
|
||||
}
|
||||
85
ViewModels/DashboardViewModel.cs
Normale Datei
85
ViewModels/DashboardViewModel.cs
Normale Datei
@@ -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
24
ViewModels/MainViewModel.cs
Normale Datei
@@ -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}";
|
||||
}
|
||||
}
|
||||
112
ViewModels/PrinterStatusViewModel.cs
Normale Datei
112
ViewModels/PrinterStatusViewModel.cs
Normale Datei
@@ -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
60
ViewModels/RelayCommand.cs
Normale Datei
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
187
ViewModels/SettingsViewModel.cs
Normale Datei
187
ViewModels/SettingsViewModel.cs
Normale Datei
@@ -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
23
ViewModels/ViewModelBase.cs
Normale Datei
@@ -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
81
Views/DashboardView.xaml
Normale Datei
@@ -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
11
Views/DashboardView.xaml.cs
Normale Datei
@@ -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
16
Views/MainWindow.xaml
Normale Datei
@@ -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
19
Views/MainWindow.xaml.cs
Normale Datei
@@ -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
130
Views/SettingsView.xaml
Normale Datei
@@ -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
11
Views/SettingsView.xaml.cs
Normale Datei
@@ -0,0 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace PrinterMonitor.Views;
|
||||
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
19
appsettings.json
Normale Datei
19
appsettings.json
Normale Datei
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren