Initial commit
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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
42
.gitignore
vendored
Normale Datei
42
.gitignore
vendored
Normale Datei
@@ -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
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
lib/BitFaster.Caching.dll
Normale Datei
BIN
lib/BitFaster.Caching.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Microsoft.Extensions.DependencyInjection.Abstractions.dll
Normale Datei
BIN
lib/Microsoft.Extensions.DependencyInjection.Abstractions.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Microsoft.Extensions.DependencyInjection.dll
Normale Datei
BIN
lib/Microsoft.Extensions.DependencyInjection.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Microsoft.Extensions.Logging.Abstractions.dll
Normale Datei
BIN
lib/Microsoft.Extensions.Logging.Abstractions.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Microsoft.Extensions.Logging.dll
Normale Datei
BIN
lib/Microsoft.Extensions.Logging.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Microsoft.Extensions.Options.dll
Normale Datei
BIN
lib/Microsoft.Extensions.Options.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Microsoft.Extensions.Primitives.dll
Normale Datei
BIN
lib/Microsoft.Extensions.Primitives.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Newtonsoft.Json.dll
Normale Datei
BIN
lib/Newtonsoft.Json.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Opc.Ua.Client.dll
Normale Datei
BIN
lib/Opc.Ua.Client.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Opc.Ua.Configuration.dll
Normale Datei
BIN
lib/Opc.Ua.Configuration.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Opc.Ua.Core.dll
Normale Datei
BIN
lib/Opc.Ua.Core.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Opc.Ua.Security.Certificates.dll
Normale Datei
BIN
lib/Opc.Ua.Security.Certificates.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/Opc.Ua.Types.dll
Normale Datei
BIN
lib/Opc.Ua.Types.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/System.Collections.Immutable.dll
Normale Datei
BIN
lib/System.Collections.Immutable.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/System.Diagnostics.DiagnosticSource.dll
Normale Datei
BIN
lib/System.Diagnostics.DiagnosticSource.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/System.Formats.Asn1.dll
Normale Datei
BIN
lib/System.Formats.Asn1.dll
Normale Datei
Binäre Datei nicht angezeigt.
BIN
lib/System.IO.Pipelines.dll
Normale Datei
BIN
lib/System.IO.Pipelines.dll
Normale Datei
Binäre Datei nicht angezeigt.
In neuem Issue referenzieren
Einen Benutzer sperren