diff --git a/ZB.MOM.WW.LmxOpcUa.slnx b/ZB.MOM.WW.LmxOpcUa.slnx
index c956722..ca833d5 100644
--- a/ZB.MOM.WW.LmxOpcUa.slnx
+++ b/ZB.MOM.WW.LmxOpcUa.slnx
@@ -1,9 +1,15 @@
+
+
+
+
+
+
diff --git a/docs/ClientRequirements.md b/docs/ClientRequirements.md
new file mode 100644
index 0000000..f61f950
--- /dev/null
+++ b/docs/ClientRequirements.md
@@ -0,0 +1,227 @@
+# OPC UA Client Requirements
+
+## Overview
+
+Three new .NET 10 cross-platform projects providing a shared OPC UA client library, a CLI tool, and an Avalonia desktop UI. All projects target Windows and macOS.
+
+## Projects
+
+| Project | Type | Purpose |
+|---------|------|---------|
+| `ZB.MOM.WW.LmxOpcUa.Client.Shared` | Class library | Core OPC UA client, models, interfaces |
+| `ZB.MOM.WW.LmxOpcUa.Client.CLI` | Console app | Command-line interface using CliFx |
+| `ZB.MOM.WW.LmxOpcUa.Client.UI` | Avalonia app | Desktop UI with tree browser, subscriptions, alarms |
+| `ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests` | Test project | Unit tests for shared library |
+| `ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests` | Test project | Unit tests for CLI commands |
+| `ZB.MOM.WW.LmxOpcUa.Client.UI.Tests` | Test project | Unit tests for UI view models |
+
+## Technology Stack
+
+- .NET 10, C#
+- OPC UA: OPCFoundation.NetStandard.Opc.Ua.Client
+- Logging: Serilog
+- CLI: CliFx
+- UI: Avalonia 11.x with CommunityToolkit.Mvvm
+- Tests: xUnit 3, Shouldly, Microsoft.Testing.Platform runner
+
+## Client.Shared
+
+### ConnectionSettings Model
+
+```
+EndpointUrl: string (required)
+FailoverUrls: string[] (optional)
+Username: string? (optional, first-class property)
+Password: string? (optional, first-class property)
+SecurityMode: enum (None, Sign, SignAndEncrypt) — default None
+SessionTimeoutSeconds: int — default 60
+AutoAcceptCertificates: bool — default true
+CertificateStorePath: string? — default platform-appropriate location
+```
+
+### IOpcUaClientService Interface
+
+Single service interface covering all OPC UA operations:
+
+**Lifecycle:**
+- `ConnectAsync(ConnectionSettings)` — connect to server, handle endpoint discovery, security, auth
+- `DisconnectAsync()` — close session cleanly
+- `IsConnected` property
+
+**Read/Write:**
+- `ReadValueAsync(NodeId)` — returns DataValue (value, status, timestamps)
+- `WriteValueAsync(NodeId, object value)` — auto-detects target type, returns StatusCode
+
+**Browse:**
+- `BrowseAsync(NodeId? parent)` — returns list of BrowseResult (NodeId, DisplayName, NodeClass)
+- Lazy-load compatible (browse one level at a time)
+
+**Subscribe:**
+- `SubscribeAsync(NodeId, int intervalMs)` — create monitored item subscription
+- `UnsubscribeAsync(NodeId)` — remove monitored item
+- `event DataChanged` — fires on value change with (NodeId, DataValue)
+
+**Alarms:**
+- `SubscribeAlarmsAsync(NodeId? source, int intervalMs)` — subscribe to alarm events
+- `UnsubscribeAlarmsAsync()` — remove alarm subscription
+- `RequestConditionRefreshAsync()` — trigger condition refresh
+- `event AlarmEvent` — fires on alarm state change with AlarmEventArgs
+
+**History:**
+- `HistoryReadRawAsync(NodeId, DateTime start, DateTime end, int maxValues)` — raw historical values
+- `HistoryReadAggregateAsync(NodeId, DateTime start, DateTime end, AggregateType, double intervalMs)` — aggregated values
+
+**Redundancy:**
+- `GetRedundancyInfoAsync()` — returns RedundancyInfo (mode, service level, server URIs, app URI)
+
+**Failover:**
+- Automatic failover across FailoverUrls with keep-alive monitoring
+- `event ConnectionStateChanged` — fires on connect/disconnect/failover
+
+### Models
+
+- `BrowseResult`: NodeId, DisplayName, NodeClass, HasChildren
+- `AlarmEventArgs`: SourceName, ConditionName, Severity, Message, Retain, ActiveState, AckedState, Time
+- `RedundancyInfo`: Mode, ServiceLevel, ServerUris, ApplicationUri
+- `ConnectionState`: enum (Disconnected, Connecting, Connected, Reconnecting)
+- `AggregateType`: enum (Average, Minimum, Maximum, Count, Start, End)
+
+### Type Conversion
+
+Port the existing `ConvertValue` logic from the CLI tool: reads the current node value to determine the target type, then coerces the input value.
+
+### Certificate Management
+
+- Cross-platform certificate store path (default: `{AppData}/LmxOpcUaClient/pki/`)
+- Auto-generate client certificate on first use
+- Auto-accept untrusted server certificates (configurable)
+
+### Logging
+
+Serilog with `ILogger` passed via constructor or `Log.ForContext()`. No sinks configured in the library — consumers configure sinks.
+
+## Client.CLI
+
+### Commands
+
+Port all 8 commands from the existing `tools/opcuacli-dotnet/`:
+
+| Command | Description |
+|---------|-------------|
+| `connect` | Test server connectivity |
+| `read` | Read a node value |
+| `write` | Write a value to a node |
+| `browse` | Browse address space (with depth/recursive) |
+| `subscribe` | Monitor node for value changes |
+| `historyread` | Read historical data (raw + aggregates) |
+| `alarms` | Subscribe to alarm events |
+| `redundancy` | Query redundancy state |
+
+All commands use the shared `IOpcUaClientService`. Each command:
+1. Creates `ConnectionSettings` from CLI options
+2. Creates `OpcUaClientService`
+3. Calls the appropriate method
+4. Formats and prints results
+
+### Common Options (all commands)
+
+- `-u, --url` (required): Endpoint URL
+- `-U, --username`: Username
+- `-P, --password`: Password
+- `-S, --security`: Security mode (none/sign/encrypt)
+- `-F, --failover-urls`: Comma-separated failover endpoints
+
+### Logging
+
+Serilog console sink at Warning level by default, with `--verbose` flag for Debug.
+
+## Client.UI
+
+### Window Layout
+
+Single-window Avalonia application:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ [Endpoint URL] [User] [Pass] [Security▼] [Connect] │
+│ Redundancy: Mode=Warm ServiceLevel=200 AppUri=... │
+├──────────────┬──────────────────────────────────────────┤
+│ │ ┌─Read/Write─┬─Subscriptions─┬─Alarms─┬─History─┐│
+│ Address │ │ Node: ns=3;s=Tag.Attr ││
+│ Space │ │ Value: 42.5 ││
+│ Tree │ │ Status: Good ││
+│ Browser │ │ [Write: ____] [Send] ││
+│ │ │ ││
+│ (lazy-load) │ │ ││
+│ │ └──────────────────────────────────────┘│
+├──────────────┴──────────────────────────────────────────┤
+│ Status: Connected | Session: abc123 | 3 subscriptions │
+└─────────────────────────────────────────────────────────┘
+```
+
+### Views and ViewModels (CommunityToolkit.Mvvm)
+
+**MainWindowViewModel:**
+- Connection settings properties (bound to top bar inputs)
+- ConnectCommand / DisconnectCommand (RelayCommand)
+- ConnectionState property
+- RedundancyInfo property
+- SelectedTreeNode property
+- StatusMessage property
+
+**BrowseTreeViewModel:**
+- Root nodes collection (ObservableCollection)
+- Lazy-load children on expand via `BrowseAsync`
+- TreeNodeViewModel: NodeId, DisplayName, NodeClass, Children, IsExpanded, HasChildren
+
+**ReadWriteViewModel:**
+- SelectedNode (from tree selection)
+- CurrentValue, Status, SourceTimestamp
+- WriteValue input + WriteCommand
+- Auto-read on node selection
+
+**SubscriptionsViewModel:**
+- ActiveSubscriptions collection (ObservableCollection)
+- AddSubscription / RemoveSubscription commands
+- Live value updates dispatched to UI thread
+- Columns: NodeId, Value, Status, Timestamp
+
+**AlarmsViewModel:**
+- AlarmEvents collection (ObservableCollection)
+- SubscribeCommand / UnsubscribeCommand / RefreshCommand
+- MonitoredNode property
+- Live alarm events dispatched to UI thread
+
+**HistoryViewModel:**
+- SelectedNode (from tree selection)
+- StartTime, EndTime, MaxValues, AggregateType, Interval
+- ReadCommand
+- Results collection (ObservableCollection)
+- Columns: Timestamp, Value, Status
+
+### UI Thread Dispatch
+
+All events from `IOpcUaClientService` must be dispatched to the Avalonia UI thread via `Dispatcher.UIThread.Post()` before updating ObservableCollections.
+
+## Test Projects
+
+### Client.Shared.Tests
+- ConnectionSettings validation
+- Type conversion (ConvertValue)
+- BrowseResult model construction
+- AlarmEventArgs model construction
+- FailoverUrl parsing
+
+### Client.CLI.Tests
+- Command option parsing (via CliFx test infrastructure)
+- Output formatting
+
+### Client.UI.Tests
+- ViewModel property change notifications
+- Command can-execute logic
+- Tree node lazy-load behavior (with mocked IOpcUaClientService)
+
+### Test Framework
+- xUnit 3 with Microsoft.Testing.Platform runner
+- Shouldly for assertions
+- No live OPC UA server required — mock IOpcUaClientService for unit tests
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs
new file mode 100644
index 0000000..95a4328
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs
@@ -0,0 +1,99 @@
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using Serilog;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI;
+
+///
+/// Abstract base class for all CLI commands providing common connection options and helpers.
+///
+public abstract class CommandBase : ICommand
+{
+ internal static readonly IOpcUaClientServiceFactory DefaultFactory = new OpcUaClientServiceFactory();
+
+ private readonly IOpcUaClientServiceFactory _factory;
+
+ protected CommandBase(IOpcUaClientServiceFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
+ public string Url { get; init; } = default!;
+
+ [CommandOption("username", 'U', Description = "Username for authentication")]
+ public string? Username { get; init; }
+
+ [CommandOption("password", 'P', Description = "Password for authentication")]
+ public string? Password { get; init; }
+
+ [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt, signandencrypt (default: none)")]
+ public string Security { get; init; } = "none";
+
+ [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
+ public string? FailoverUrls { get; init; }
+
+ [CommandOption("verbose", Description = "Enable verbose/debug logging")]
+ public bool Verbose { get; init; }
+
+ ///
+ /// Creates a from the common command options.
+ ///
+ protected ConnectionSettings CreateConnectionSettings()
+ {
+ var securityMode = SecurityModeMapper.FromString(Security);
+ var failoverUrls = !string.IsNullOrWhiteSpace(FailoverUrls)
+ ? FailoverUrlParser.Parse(Url, FailoverUrls)
+ : null;
+
+ var settings = new ConnectionSettings
+ {
+ EndpointUrl = Url,
+ FailoverUrls = failoverUrls,
+ Username = Username,
+ Password = Password,
+ SecurityMode = securityMode,
+ AutoAcceptCertificates = true
+ };
+
+ return settings;
+ }
+
+ ///
+ /// Creates a new , connects it using the common options,
+ /// and returns both the service and the connection info.
+ ///
+ protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(CancellationToken ct)
+ {
+ var service = _factory.Create();
+ var settings = CreateConnectionSettings();
+ var info = await service.ConnectAsync(settings, ct);
+ return (service, info);
+ }
+
+ ///
+ /// Configures Serilog based on the verbose flag.
+ ///
+ protected void ConfigureLogging()
+ {
+ var config = new LoggerConfiguration();
+ if (Verbose)
+ {
+ config.MinimumLevel.Debug()
+ .WriteTo.Console();
+ }
+ else
+ {
+ config.MinimumLevel.Warning()
+ .WriteTo.Console();
+ }
+
+ Log.Logger = config.CreateLogger();
+ }
+
+ public abstract ValueTask ExecuteAsync(IConsole console);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs
new file mode 100644
index 0000000..42cd6a5
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs
@@ -0,0 +1,86 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("alarms", Description = "Subscribe to alarm events")]
+public class AlarmsCommand : CommandBase
+{
+ [CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
+ public string? NodeId { get; init; }
+
+ [CommandOption("interval", 'i', Description = "Publishing interval in milliseconds")]
+ public int Interval { get; init; } = 1000;
+
+ [CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
+ public bool Refresh { get; init; }
+
+ public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var sourceNodeId = NodeIdParser.Parse(NodeId);
+
+ service.AlarmEvent += (_, e) =>
+ {
+ console.Output.WriteLine($"[{e.Time:O}] ALARM {e.SourceName}");
+ console.Output.WriteLine($" Condition: {e.ConditionName}");
+ var activeStr = e.ActiveState ? "Active" : "Inactive";
+ var ackedStr = e.AckedState ? "Acknowledged" : "Unacknowledged";
+ console.Output.WriteLine($" State: {activeStr}, {ackedStr}");
+ console.Output.WriteLine($" Severity: {e.Severity}");
+ if (!string.IsNullOrEmpty(e.Message))
+ console.Output.WriteLine($" Message: {e.Message}");
+ console.Output.WriteLine($" Retain: {e.Retain}");
+ console.Output.WriteLine();
+ };
+
+ await service.SubscribeAlarmsAsync(sourceNodeId, Interval, ct);
+ await console.Output.WriteLineAsync(
+ $"Subscribed to alarm events (interval: {Interval}ms). Press Ctrl+C to stop.");
+
+ if (Refresh)
+ {
+ try
+ {
+ await service.RequestConditionRefreshAsync(ct);
+ await console.Output.WriteLineAsync("Condition refresh requested.");
+ }
+ catch (Exception ex)
+ {
+ await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}");
+ }
+ }
+
+ // Wait until cancellation
+ try
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on Ctrl+C
+ }
+
+ await service.UnsubscribeAlarmsAsync(default);
+ await console.Output.WriteLineAsync("Unsubscribed.");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs
new file mode 100644
index 0000000..c241abe
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs
@@ -0,0 +1,79 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("browse", Description = "Browse the OPC UA address space")]
+public class BrowseCommand : CommandBase
+{
+ [CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
+ public string? NodeId { get; init; }
+
+ [CommandOption("depth", 'd', Description = "Maximum browse depth")]
+ public int Depth { get; init; } = 1;
+
+ [CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
+ public bool Recursive { get; init; }
+
+ public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var startNode = NodeIdParser.Parse(NodeId);
+ var maxDepth = Recursive ? Depth : 1;
+
+ await BrowseNodeAsync(service, console, startNode, maxDepth, 0, ct);
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+
+ private static async Task BrowseNodeAsync(
+ IOpcUaClientService service,
+ IConsole console,
+ NodeId? nodeId,
+ int maxDepth,
+ int currentDepth,
+ CancellationToken ct)
+ {
+ var indent = new string(' ', currentDepth * 2);
+ var results = await service.BrowseAsync(nodeId, ct);
+
+ foreach (var result in results)
+ {
+ var marker = result.NodeClass switch
+ {
+ "Object" => "[Object]",
+ "Variable" => "[Variable]",
+ "Method" => "[Method]",
+ _ => $"[{result.NodeClass}]"
+ };
+
+ await console.Output.WriteLineAsync(
+ $"{indent}{marker} {result.DisplayName} (NodeId: {result.NodeId})");
+
+ if (currentDepth + 1 < maxDepth && result.HasChildren)
+ {
+ var childNodeId = NodeIdParser.Parse(result.NodeId);
+ await BrowseNodeAsync(service, console, childNodeId, maxDepth, currentDepth + 1, ct);
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs
new file mode 100644
index 0000000..249905b
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs
@@ -0,0 +1,35 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("connect", Description = "Test connection to an OPC UA server")]
+public class ConnectCommand : CommandBase
+{
+ public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ (service, var info) = await CreateServiceAndConnectAsync(console.RegisterCancellationHandler());
+
+ await console.Output.WriteLineAsync($"Connected to: {info.EndpointUrl}");
+ await console.Output.WriteLineAsync($"Server: {info.ServerName}");
+ await console.Output.WriteLineAsync($"Security Mode: {info.SecurityMode}");
+ await console.Output.WriteLineAsync($"Security Policy: {info.SecurityPolicyUri}");
+ await console.Output.WriteLineAsync("Connection successful.");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
new file mode 100644
index 0000000..30be22e
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
@@ -0,0 +1,106 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("historyread", Description = "Read historical data from a node")]
+public class HistoryReadCommand : CommandBase
+{
+ [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
+ public string NodeId { get; init; } = default!;
+
+ [CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")]
+ public string? StartTime { get; init; }
+
+ [CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")]
+ public string? EndTime { get; init; }
+
+ [CommandOption("max", Description = "Maximum number of values to return")]
+ public int MaxValues { get; init; } = 1000;
+
+ [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End")]
+ public string? Aggregate { get; init; }
+
+ [CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
+ public double IntervalMs { get; init; } = 3600000;
+
+ public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var nodeId = NodeIdParser.ParseRequired(NodeId);
+ var start = string.IsNullOrEmpty(StartTime)
+ ? DateTime.UtcNow.AddHours(-24)
+ : DateTime.Parse(StartTime).ToUniversalTime();
+ var end = string.IsNullOrEmpty(EndTime)
+ ? DateTime.UtcNow
+ : DateTime.Parse(EndTime).ToUniversalTime();
+
+ IReadOnlyList values;
+
+ if (string.IsNullOrEmpty(Aggregate))
+ {
+ await console.Output.WriteLineAsync(
+ $"History for {NodeId} ({start:yyyy-MM-dd HH:mm} -> {end:yyyy-MM-dd HH:mm})");
+ values = await service.HistoryReadRawAsync(nodeId, start, end, MaxValues, ct);
+ }
+ else
+ {
+ var aggregateType = ParseAggregateType(Aggregate);
+ await console.Output.WriteLineAsync(
+ $"History for {NodeId} ({Aggregate}, interval={IntervalMs}ms)");
+ values = await service.HistoryReadAggregateAsync(
+ nodeId, start, end, aggregateType, IntervalMs, ct);
+ }
+
+ await console.Output.WriteLineAsync();
+ await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}");
+
+ foreach (var dv in values)
+ {
+ var status = StatusCode.IsGood(dv.StatusCode) ? "Good"
+ : StatusCode.IsBad(dv.StatusCode) ? "Bad"
+ : "Uncertain";
+ await console.Output.WriteLineAsync(
+ $"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
+ }
+
+ await console.Output.WriteLineAsync();
+ await console.Output.WriteLineAsync($"{values.Count} values returned.");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+
+ private static AggregateType ParseAggregateType(string name)
+ {
+ return name.Trim().ToLowerInvariant() switch
+ {
+ "average" or "avg" => AggregateType.Average,
+ "minimum" or "min" => AggregateType.Minimum,
+ "maximum" or "max" => AggregateType.Maximum,
+ "count" => AggregateType.Count,
+ "start" or "first" => AggregateType.Start,
+ "end" or "last" => AggregateType.End,
+ _ => throw new ArgumentException(
+ $"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End")
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs
new file mode 100644
index 0000000..273812e
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs
@@ -0,0 +1,43 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("read", Description = "Read a value from a node")]
+public class ReadCommand : CommandBase
+{
+ [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
+ public string NodeId { get; init; } = default!;
+
+ public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var nodeId = NodeIdParser.ParseRequired(NodeId);
+ var value = await service.ReadValueAsync(nodeId, ct);
+
+ await console.Output.WriteLineAsync($"Node: {NodeId}");
+ await console.Output.WriteLineAsync($"Value: {value.Value}");
+ await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
+ await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
+ await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs
new file mode 100644
index 0000000..8cb0590
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs
@@ -0,0 +1,46 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
+public class RedundancyCommand : CommandBase
+{
+ public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var info = await service.GetRedundancyInfoAsync(ct);
+
+ await console.Output.WriteLineAsync($"Redundancy Mode: {info.Mode}");
+ await console.Output.WriteLineAsync($"Service Level: {info.ServiceLevel}");
+
+ if (info.ServerUris.Length > 0)
+ {
+ await console.Output.WriteLineAsync("Server URIs:");
+ foreach (var uri in info.ServerUris)
+ {
+ await console.Output.WriteLineAsync($" - {uri}");
+ }
+ }
+
+ await console.Output.WriteLineAsync($"Application URI: {info.ApplicationUri}");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs
new file mode 100644
index 0000000..fe74439
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs
@@ -0,0 +1,62 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("subscribe", Description = "Monitor a node for value changes")]
+public class SubscribeCommand : CommandBase
+{
+ [CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
+ public string NodeId { get; init; } = default!;
+
+ [CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
+ public int Interval { get; init; } = 1000;
+
+ public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var nodeId = NodeIdParser.ParseRequired(NodeId);
+
+ service.DataChanged += (_, e) =>
+ {
+ console.Output.WriteLine(
+ $"[{e.Value.SourceTimestamp:O}] {e.NodeId} = {e.Value.Value} ({e.Value.StatusCode})");
+ };
+
+ await service.SubscribeAsync(nodeId, Interval, ct);
+ await console.Output.WriteLineAsync(
+ $"Subscribed to {NodeId} (interval: {Interval}ms). Press Ctrl+C to stop.");
+
+ // Wait until cancellation
+ try
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on Ctrl+C
+ }
+
+ await service.UnsubscribeAsync(nodeId, default);
+ await console.Output.WriteLineAsync("Unsubscribed.");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs
new file mode 100644
index 0000000..55bac7b
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs
@@ -0,0 +1,52 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+
+[Command("write", Description = "Write a value to a node")]
+public class WriteCommand : CommandBase
+{
+ [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
+ public string NodeId { get; init; } = default!;
+
+ [CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
+ public string Value { get; init; } = default!;
+
+ public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ ConfigureLogging();
+ IOpcUaClientService? service = null;
+ try
+ {
+ var ct = console.RegisterCancellationHandler();
+ (service, _) = await CreateServiceAndConnectAsync(ct);
+
+ var nodeId = NodeIdParser.ParseRequired(NodeId);
+
+ // Read current value to determine type for conversion
+ var currentValue = await service.ReadValueAsync(nodeId, ct);
+ var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
+
+ var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
+
+ if (StatusCode.IsGood(statusCode))
+ await console.Output.WriteLineAsync($"Write successful: {NodeId} = {typedValue}");
+ else
+ await console.Output.WriteLineAsync($"Write failed: {statusCode}");
+ }
+ finally
+ {
+ if (service != null)
+ {
+ await service.DisconnectAsync();
+ service.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs
new file mode 100644
index 0000000..ef45b5a
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs
@@ -0,0 +1,61 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+
+///
+/// Parses node ID strings into OPC UA objects.
+/// Supports standard OPC UA format (e.g., "ns=2;s=MyNode", "i=85") and bare numeric IDs.
+///
+public static class NodeIdParser
+{
+ ///
+ /// Parses a string into a . Returns null if the input is null or empty.
+ ///
+ /// The node ID string to parse.
+ /// A parsed , or null if input is null/empty.
+ /// Thrown when the string cannot be parsed as a valid NodeId.
+ public static NodeId? Parse(string? nodeIdString)
+ {
+ if (string.IsNullOrWhiteSpace(nodeIdString))
+ return null;
+
+ var trimmed = nodeIdString.Trim();
+
+ // Standard OPC UA format: ns=X;s=..., ns=X;i=..., ns=X;g=..., ns=X;b=...
+ // Also: s=..., i=..., g=..., b=... (namespace 0 implied)
+ if (trimmed.Contains('='))
+ {
+ try
+ {
+ return NodeId.Parse(trimmed);
+ }
+ catch (Exception ex)
+ {
+ throw new FormatException($"Invalid node ID format: '{nodeIdString}'", ex);
+ }
+ }
+
+ // Bare numeric: treat as namespace 0, numeric identifier
+ if (uint.TryParse(trimmed, out var numericId))
+ {
+ return new NodeId(numericId);
+ }
+
+ throw new FormatException($"Invalid node ID format: '{nodeIdString}'. Expected format like 'ns=2;s=MyNode', 'i=85', or a numeric ID.");
+ }
+
+ ///
+ /// Parses a string into a , throwing if the input is null or empty.
+ ///
+ /// The node ID string to parse.
+ /// A parsed .
+ /// Thrown when the input is null or empty.
+ /// Thrown when the string cannot be parsed as a valid NodeId.
+ public static NodeId ParseRequired(string? nodeIdString)
+ {
+ var result = Parse(nodeIdString);
+ if (result == null)
+ throw new ArgumentException("Node ID is required but was not provided.");
+ return result;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs
new file mode 100644
index 0000000..b729419
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs
@@ -0,0 +1,18 @@
+using CliFx;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI;
+
+return await new CliApplicationBuilder()
+ .AddCommandsFromThisAssembly()
+ .UseTypeActivator(type =>
+ {
+ // Inject the default factory into commands that derive from CommandBase
+ if (type.IsSubclassOf(typeof(CommandBase)))
+ {
+ return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
+ }
+ return Activator.CreateInstance(type)!;
+ })
+ .SetExecutableName("lmxopcua-cli")
+ .SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server")
+ .Build()
+ .RunAsync(args);
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj
new file mode 100644
index 0000000..cba342c
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ ZB.MOM.WW.LmxOpcUa.Client.CLI
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs
new file mode 100644
index 0000000..69411af
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs
@@ -0,0 +1,75 @@
+using Opc.Ua;
+using Opc.Ua.Configuration;
+using Serilog;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Production implementation that builds a real OPC UA ApplicationConfiguration.
+///
+internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfigurationFactory
+{
+ private static readonly ILogger Logger = Log.ForContext();
+
+ public async Task CreateAsync(ConnectionSettings settings, CancellationToken ct)
+ {
+ var storePath = settings.CertificateStorePath;
+
+ var config = new ApplicationConfiguration
+ {
+ ApplicationName = "LmxOpcUaClient",
+ ApplicationUri = "urn:localhost:LmxOpcUaClient",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ ApplicationCertificate = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(storePath, "own")
+ },
+ TrustedIssuerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(storePath, "issuer")
+ },
+ TrustedPeerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(storePath, "trusted")
+ },
+ RejectedCertificateStore = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(storePath, "rejected")
+ },
+ AutoAcceptUntrustedCertificates = settings.AutoAcceptCertificates
+ },
+ ClientConfiguration = new ClientConfiguration
+ {
+ DefaultSessionTimeout = settings.SessionTimeoutSeconds * 1000
+ }
+ };
+
+ await config.Validate(ApplicationType.Client);
+
+ if (settings.AutoAcceptCertificates)
+ {
+ config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
+ }
+
+ if (settings.SecurityMode != Models.SecurityMode.None)
+ {
+ var app = new ApplicationInstance
+ {
+ ApplicationName = "LmxOpcUaClient",
+ ApplicationType = ApplicationType.Client,
+ ApplicationConfiguration = config
+ };
+ await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
+ }
+
+ Logger.Debug("ApplicationConfiguration created for {EndpointUrl}", settings.EndpointUrl);
+ return config;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs
new file mode 100644
index 0000000..353b935
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs
@@ -0,0 +1,62 @@
+using Opc.Ua;
+using Opc.Ua.Client;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Production endpoint discovery that queries the real server.
+///
+internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
+{
+ private static readonly ILogger Logger = Log.ForContext();
+
+ public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode)
+ {
+ if (requestedMode == MessageSecurityMode.None)
+ {
+#pragma warning disable CS0618 // Acceptable for endpoint selection
+ return CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
+#pragma warning restore CS0618
+ }
+
+ using var client = DiscoveryClient.Create(new Uri(endpointUrl));
+ var allEndpoints = client.GetEndpoints(null);
+
+ EndpointDescription? best = null;
+
+ foreach (var ep in allEndpoints)
+ {
+ if (ep.SecurityMode != requestedMode)
+ continue;
+
+ if (best == null)
+ {
+ best = ep;
+ continue;
+ }
+
+ if (ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
+ best = ep;
+ }
+
+ if (best == null)
+ {
+ var available = string.Join(", ", allEndpoints.Select(e => $"{e.SecurityMode}/{e.SecurityPolicyUri}"));
+ throw new InvalidOperationException(
+ $"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
+ }
+
+ // Rewrite endpoint URL hostname to match user-supplied hostname
+ var serverUri = new Uri(best.EndpointUrl);
+ var requestedUri = new Uri(endpointUrl);
+ if (serverUri.Host != requestedUri.Host)
+ {
+ var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host };
+ best.EndpointUrl = builder.ToString();
+ Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host, requestedUri.Host);
+ }
+
+ return best;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
new file mode 100644
index 0000000..8f3af60
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
@@ -0,0 +1,230 @@
+using Opc.Ua;
+using Opc.Ua.Client;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Production session adapter wrapping a real OPC UA Session.
+///
+internal sealed class DefaultSessionAdapter : ISessionAdapter
+{
+ private static readonly ILogger Logger = Log.ForContext();
+ private readonly Session _session;
+
+ public DefaultSessionAdapter(Session session)
+ {
+ _session = session;
+ }
+
+ public bool Connected => _session.Connected;
+ public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
+ public string SessionName => _session.SessionName ?? string.Empty;
+ public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
+ public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty;
+ public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
+ public string SecurityPolicyUri => _session.Endpoint?.SecurityPolicyUri ?? string.Empty;
+ public NamespaceTable NamespaceUris => _session.NamespaceUris;
+
+ public void RegisterKeepAliveHandler(Action callback)
+ {
+ _session.KeepAlive += (_, e) =>
+ {
+ var isGood = e.Status == null || ServiceResult.IsGood(e.Status);
+ callback(isGood);
+ };
+ }
+
+ public async Task ReadValueAsync(NodeId nodeId, CancellationToken ct)
+ {
+ return await _session.ReadValueAsync(nodeId, ct);
+ }
+
+ public async Task WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct)
+ {
+ var writeValue = new WriteValue
+ {
+ NodeId = nodeId,
+ AttributeId = Attributes.Value,
+ Value = value
+ };
+
+ var writeCollection = new WriteValueCollection { writeValue };
+ var response = await _session.WriteAsync(null, writeCollection, ct);
+ return response.Results[0];
+ }
+
+ public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
+ NodeId nodeId, uint nodeClassMask, CancellationToken ct)
+ {
+ var (_, continuationPoint, references) = await _session.BrowseAsync(
+ null,
+ null,
+ nodeId,
+ 0u,
+ BrowseDirection.Forward,
+ ReferenceTypeIds.HierarchicalReferences,
+ true,
+ nodeClassMask);
+
+ return (continuationPoint, references ?? new ReferenceDescriptionCollection());
+ }
+
+ public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
+ byte[] continuationPoint, CancellationToken ct)
+ {
+ var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint);
+ return (nextCp, nextRefs ?? new ReferenceDescriptionCollection());
+ }
+
+ public async Task HasChildrenAsync(NodeId nodeId, CancellationToken ct)
+ {
+ var (_, _, references) = await _session.BrowseAsync(
+ null,
+ null,
+ nodeId,
+ 1u,
+ BrowseDirection.Forward,
+ ReferenceTypeIds.HierarchicalReferences,
+ true,
+ 0u);
+
+ return references != null && references.Count > 0;
+ }
+
+ public async Task> HistoryReadRawAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
+ {
+ var details = new ReadRawModifiedDetails
+ {
+ StartTime = startTime,
+ EndTime = endTime,
+ NumValuesPerNode = (uint)maxValues,
+ IsReadModified = false,
+ ReturnBounds = false
+ };
+
+ var nodesToRead = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId { NodeId = nodeId }
+ };
+
+ var allValues = new List();
+ byte[]? continuationPoint = null;
+
+ do
+ {
+ if (continuationPoint != null)
+ nodesToRead[0].ContinuationPoint = continuationPoint;
+
+ _session.HistoryRead(
+ null,
+ new ExtensionObject(details),
+ TimestampsToReturn.Source,
+ continuationPoint != null,
+ nodesToRead,
+ out var results,
+ out _);
+
+ if (results == null || results.Count == 0)
+ break;
+
+ var result = results[0];
+ if (StatusCode.IsBad(result.StatusCode))
+ break;
+
+ if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
+ {
+ allValues.AddRange(historyData.DataValues);
+ }
+
+ continuationPoint = result.ContinuationPoint;
+ }
+ while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
+
+ return allValues;
+ }
+
+ public async Task> HistoryReadAggregateAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, CancellationToken ct)
+ {
+ var details = new ReadProcessedDetails
+ {
+ StartTime = startTime,
+ EndTime = endTime,
+ ProcessingInterval = intervalMs,
+ AggregateType = new NodeIdCollection { aggregateId }
+ };
+
+ var nodesToRead = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId { NodeId = nodeId }
+ };
+
+ _session.HistoryRead(
+ null,
+ new ExtensionObject(details),
+ TimestampsToReturn.Source,
+ false,
+ nodesToRead,
+ out var results,
+ out _);
+
+ var allValues = new List();
+
+ if (results != null && results.Count > 0)
+ {
+ var result = results[0];
+ if (!StatusCode.IsBad(result.StatusCode) &&
+ result.HistoryData is ExtensionObject ext &&
+ ext.Body is HistoryData historyData)
+ {
+ allValues.AddRange(historyData.DataValues);
+ }
+ }
+
+ return allValues;
+ }
+
+ public async Task CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
+ {
+ var subscription = new Subscription(_session.DefaultSubscription)
+ {
+ PublishingInterval = publishingIntervalMs,
+ DisplayName = "ClientShared_Subscription"
+ };
+
+ _session.AddSubscription(subscription);
+ await subscription.CreateAsync(ct);
+
+ return new DefaultSubscriptionAdapter(subscription);
+ }
+
+ public async Task CloseAsync(CancellationToken ct)
+ {
+ try
+ {
+ if (_session.Connected)
+ {
+ _session.Close();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "Error closing session");
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (_session.Connected)
+ {
+ _session.Close();
+ }
+ }
+ catch { }
+ _session.Dispose();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs
new file mode 100644
index 0000000..9999595
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs
@@ -0,0 +1,37 @@
+using Opc.Ua;
+using Opc.Ua.Client;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Production session factory that creates real OPC UA sessions.
+///
+internal sealed class DefaultSessionFactory : ISessionFactory
+{
+ private static readonly ILogger Logger = Log.ForContext();
+
+ public async Task CreateSessionAsync(
+ ApplicationConfiguration config,
+ EndpointDescription endpoint,
+ string sessionName,
+ uint sessionTimeoutMs,
+ UserIdentity identity,
+ CancellationToken ct)
+ {
+ var endpointConfig = EndpointConfiguration.Create(config);
+ var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
+
+ var session = await Session.Create(
+ config,
+ configuredEndpoint,
+ false,
+ sessionName,
+ sessionTimeoutMs,
+ identity,
+ null);
+
+ Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
+ return new DefaultSessionAdapter(session);
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs
new file mode 100644
index 0000000..ba4c2c6
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs
@@ -0,0 +1,121 @@
+using Opc.Ua;
+using Opc.Ua.Client;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Production subscription adapter wrapping a real OPC UA Subscription.
+///
+internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
+{
+ private static readonly ILogger Logger = Log.ForContext();
+ private readonly Subscription _subscription;
+ private readonly Dictionary _monitoredItems = new();
+
+ public DefaultSubscriptionAdapter(Subscription subscription)
+ {
+ _subscription = subscription;
+ }
+
+ public uint SubscriptionId => _subscription.Id;
+
+ public async Task AddDataChangeMonitoredItemAsync(
+ NodeId nodeId, int samplingIntervalMs, Action onDataChange, CancellationToken ct)
+ {
+ var item = new MonitoredItem(_subscription.DefaultItem)
+ {
+ StartNodeId = nodeId,
+ DisplayName = nodeId.ToString(),
+ SamplingInterval = samplingIntervalMs
+ };
+
+ item.Notification += (_, e) =>
+ {
+ if (e.NotificationValue is MonitoredItemNotification notification)
+ {
+ onDataChange(nodeId.ToString(), notification.Value);
+ }
+ };
+
+ _subscription.AddItem(item);
+ await _subscription.ApplyChangesAsync(ct);
+
+ var handle = item.ClientHandle;
+ _monitoredItems[handle] = item;
+
+ Logger.Debug("Added data change monitored item for {NodeId}, handle={Handle}", nodeId, handle);
+ return handle;
+ }
+
+ public async Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
+ {
+ if (!_monitoredItems.TryGetValue(clientHandle, out var item))
+ return;
+
+ _subscription.RemoveItem(item);
+ await _subscription.ApplyChangesAsync(ct);
+ _monitoredItems.Remove(clientHandle);
+
+ Logger.Debug("Removed monitored item handle={Handle}", clientHandle);
+ }
+
+ public async Task AddEventMonitoredItemAsync(
+ NodeId nodeId, int samplingIntervalMs, EventFilter filter, Action onEvent, CancellationToken ct)
+ {
+ var item = new MonitoredItem(_subscription.DefaultItem)
+ {
+ StartNodeId = nodeId,
+ DisplayName = "AlarmMonitor",
+ SamplingInterval = samplingIntervalMs,
+ NodeClass = NodeClass.Object,
+ AttributeId = Attributes.EventNotifier,
+ Filter = filter
+ };
+
+ item.Notification += (_, e) =>
+ {
+ if (e.NotificationValue is EventFieldList eventFields)
+ {
+ onEvent(eventFields);
+ }
+ };
+
+ _subscription.AddItem(item);
+ await _subscription.ApplyChangesAsync(ct);
+
+ var handle = item.ClientHandle;
+ _monitoredItems[handle] = item;
+
+ Logger.Debug("Added event monitored item for {NodeId}, handle={Handle}", nodeId, handle);
+ return handle;
+ }
+
+ public async Task ConditionRefreshAsync(CancellationToken ct)
+ {
+ await _subscription.ConditionRefreshAsync(ct);
+ }
+
+ public async Task DeleteAsync(CancellationToken ct)
+ {
+ try
+ {
+ await _subscription.DeleteAsync(true);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "Error deleting subscription");
+ }
+ _monitoredItems.Clear();
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _subscription.Delete(true);
+ }
+ catch { }
+ _monitoredItems.Clear();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs
new file mode 100644
index 0000000..71e57d1
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs
@@ -0,0 +1,15 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Creates and configures an OPC UA ApplicationConfiguration.
+///
+internal interface IApplicationConfigurationFactory
+{
+ ///
+ /// Creates a validated ApplicationConfiguration for the given connection settings.
+ ///
+ Task CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs
new file mode 100644
index 0000000..f7145f8
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs
@@ -0,0 +1,15 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Abstracts OPC UA endpoint discovery for testability.
+///
+internal interface IEndpointDiscovery
+{
+ ///
+ /// Discovers endpoints at the given URL and returns the best match for the requested security mode.
+ /// Also rewrites the endpoint URL hostname to match the requested URL when they differ.
+ ///
+ EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
new file mode 100644
index 0000000..056380f
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
@@ -0,0 +1,61 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Abstracts the OPC UA session for read, write, browse, history, and subscription operations.
+///
+internal interface ISessionAdapter : IDisposable
+{
+ bool Connected { get; }
+ string SessionId { get; }
+ string SessionName { get; }
+ string EndpointUrl { get; }
+ string ServerName { get; }
+ string SecurityMode { get; }
+ string SecurityPolicyUri { get; }
+ NamespaceTable NamespaceUris { get; }
+
+ ///
+ /// Registers a keep-alive callback. The callback receives true when the session is healthy, false on failure.
+ ///
+ void RegisterKeepAliveHandler(Action callback);
+
+ Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
+ Task WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
+
+ ///
+ /// Browses forward hierarchical references from the given node.
+ /// Returns (continuationPoint, references).
+ ///
+ Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
+ NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
+
+ ///
+ /// Continues a browse from a continuation point.
+ ///
+ Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
+ byte[] continuationPoint, CancellationToken ct = default);
+
+ ///
+ /// Checks whether a node has any forward hierarchical child references.
+ ///
+ Task HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
+
+ ///
+ /// Reads raw historical data.
+ ///
+ Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default);
+
+ ///
+ /// Reads processed/aggregate historical data.
+ ///
+ Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, CancellationToken ct = default);
+
+ ///
+ /// Creates a subscription adapter for this session.
+ ///
+ Task CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
+
+ Task CloseAsync(CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs
new file mode 100644
index 0000000..92ed3b6
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs
@@ -0,0 +1,27 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Creates OPC UA sessions from a configured endpoint.
+///
+internal interface ISessionFactory
+{
+ ///
+ /// Creates a session to the given endpoint.
+ ///
+ /// The application configuration.
+ /// The configured endpoint.
+ /// The session name.
+ /// Session timeout in milliseconds.
+ /// The user identity.
+ /// Cancellation token.
+ /// A session adapter wrapping the created session.
+ Task CreateSessionAsync(
+ ApplicationConfiguration config,
+ EndpointDescription endpoint,
+ string sessionName,
+ uint sessionTimeoutMs,
+ UserIdentity identity,
+ CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs
new file mode 100644
index 0000000..68bc974
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs
@@ -0,0 +1,47 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+///
+/// Abstracts OPC UA subscription and monitored item management.
+///
+internal interface ISubscriptionAdapter : IDisposable
+{
+ uint SubscriptionId { get; }
+
+ ///
+ /// Adds a data-change monitored item and returns its client handle for tracking.
+ ///
+ /// The node to monitor.
+ /// The sampling interval in milliseconds.
+ /// Callback when data changes. Receives (nodeIdString, DataValue).
+ /// Cancellation token.
+ /// A client handle that can be used to remove the item.
+ Task AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, Action onDataChange, CancellationToken ct = default);
+
+ ///
+ /// Removes a previously added monitored item by its client handle.
+ ///
+ Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
+
+ ///
+ /// Adds an event monitored item with the given event filter.
+ ///
+ /// The node to monitor for events.
+ /// The sampling interval.
+ /// The event filter defining which fields to select.
+ /// Callback when events arrive. Receives the event field list.
+ /// Cancellation token.
+ /// A client handle for the monitored item.
+ Task AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter, Action onEvent, CancellationToken ct = default);
+
+ ///
+ /// Requests a condition refresh for this subscription.
+ ///
+ Task ConditionRefreshAsync(CancellationToken ct = default);
+
+ ///
+ /// Removes all monitored items and deletes the subscription.
+ ///
+ Task DeleteAsync(CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
new file mode 100644
index 0000000..ec3f90e
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
@@ -0,0 +1,27 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+///
+/// Maps the library's AggregateType enum to OPC UA aggregate function NodeIds.
+///
+public static class AggregateTypeMapper
+{
+ ///
+ /// Returns the OPC UA NodeId for the specified aggregate type.
+ ///
+ public static NodeId ToNodeId(AggregateType aggregate)
+ {
+ return aggregate switch
+ {
+ AggregateType.Average => ObjectIds.AggregateFunction_Average,
+ AggregateType.Minimum => ObjectIds.AggregateFunction_Minimum,
+ AggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
+ AggregateType.Count => ObjectIds.AggregateFunction_Count,
+ AggregateType.Start => ObjectIds.AggregateFunction_Start,
+ AggregateType.End => ObjectIds.AggregateFunction_End,
+ _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs
new file mode 100644
index 0000000..109acb5
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs
@@ -0,0 +1,50 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+///
+/// Parses and normalizes failover URL sets for redundant OPC UA connections.
+///
+public static class FailoverUrlParser
+{
+ ///
+ /// Parses a comma-separated failover URL string, prepending the primary URL.
+ /// Trims whitespace and deduplicates.
+ ///
+ /// The primary endpoint URL.
+ /// Optional comma-separated failover URLs.
+ /// An array with the primary URL first, followed by unique failover URLs.
+ public static string[] Parse(string primaryUrl, string? failoverCsv)
+ {
+ if (string.IsNullOrWhiteSpace(failoverCsv))
+ return new[] { primaryUrl };
+
+ var urls = new List { primaryUrl };
+ foreach (var url in failoverCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var trimmed = url.Trim();
+ if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
+ urls.Add(trimmed);
+ }
+ return urls.ToArray();
+ }
+
+ ///
+ /// Builds a failover URL set from the primary URL and an optional array of failover URLs.
+ ///
+ /// The primary endpoint URL.
+ /// Optional failover URLs.
+ /// An array with the primary URL first, followed by unique failover URLs.
+ public static string[] Parse(string primaryUrl, string[]? failoverUrls)
+ {
+ if (failoverUrls == null || failoverUrls.Length == 0)
+ return new[] { primaryUrl };
+
+ var urls = new List { primaryUrl };
+ foreach (var url in failoverUrls)
+ {
+ var trimmed = url?.Trim();
+ if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
+ urls.Add(trimmed);
+ }
+ return urls.ToArray();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs
new file mode 100644
index 0000000..0ad53b1
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs
@@ -0,0 +1,42 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+///
+/// Maps between the library's SecurityMode enum and OPC UA SDK MessageSecurityMode.
+///
+public static class SecurityModeMapper
+{
+ ///
+ /// Converts a to an OPC UA .
+ ///
+ public static MessageSecurityMode ToMessageSecurityMode(SecurityMode mode)
+ {
+ return mode switch
+ {
+ SecurityMode.None => MessageSecurityMode.None,
+ SecurityMode.Sign => MessageSecurityMode.Sign,
+ SecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
+ _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown SecurityMode value.")
+ };
+ }
+
+ ///
+ /// Parses a string to a value, case-insensitively.
+ ///
+ /// The string to parse (e.g., "none", "sign", "encrypt", "signandencrypt").
+ /// The corresponding SecurityMode.
+ /// Thrown for unrecognized values.
+ public static SecurityMode FromString(string value)
+ {
+ return (value ?? "none").Trim().ToLowerInvariant() switch
+ {
+ "none" => SecurityMode.None,
+ "sign" => SecurityMode.Sign,
+ "encrypt" or "signandencrypt" => SecurityMode.SignAndEncrypt,
+ _ => throw new ArgumentException(
+ $"Unknown security mode '{value}'. Valid values: none, sign, encrypt, signandencrypt")
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs
new file mode 100644
index 0000000..afc904e
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs
@@ -0,0 +1,32 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+///
+/// Converts raw string values into typed values based on the current value's runtime type.
+/// Ported from the CLI tool's OpcUaHelper.ConvertValue.
+///
+public static class ValueConverter
+{
+ ///
+ /// Converts a raw string value into the runtime type expected by the target node.
+ ///
+ /// The raw string supplied by the user.
+ /// The current node value used to infer the target type. May be null.
+ /// A typed value suitable for an OPC UA write request.
+ public static object ConvertValue(string rawValue, object? currentValue)
+ {
+ return currentValue switch
+ {
+ bool => bool.Parse(rawValue),
+ byte => byte.Parse(rawValue),
+ short => short.Parse(rawValue),
+ ushort => ushort.Parse(rawValue),
+ int => int.Parse(rawValue),
+ uint => uint.Parse(rawValue),
+ long => long.Parse(rawValue),
+ ulong => ulong.Parse(rawValue),
+ float => float.Parse(rawValue),
+ double => double.Parse(rawValue),
+ _ => rawValue
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs
new file mode 100644
index 0000000..9cbe49e
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs
@@ -0,0 +1,36 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+///
+/// Shared OPC UA client service contract for CLI and UI consumers.
+///
+public interface IOpcUaClientService : IDisposable
+{
+ Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
+ Task DisconnectAsync(CancellationToken ct = default);
+ bool IsConnected { get; }
+ ConnectionInfo? CurrentConnectionInfo { get; }
+
+ Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
+ Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
+
+ Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
+
+ Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
+ Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
+
+ Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
+ Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
+ Task RequestConditionRefreshAsync(CancellationToken ct = default);
+
+ Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default);
+ Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
+
+ Task GetRedundancyInfoAsync(CancellationToken ct = default);
+
+ event EventHandler? DataChanged;
+ event EventHandler? AlarmEvent;
+ event EventHandler? ConnectionStateChanged;
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs
new file mode 100644
index 0000000..f205fac
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs
@@ -0,0 +1,9 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+///
+/// Factory for creating instances.
+///
+public interface IOpcUaClientServiceFactory
+{
+ IOpcUaClientService Create();
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs
new file mode 100644
index 0000000..d3cc1d1
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs
@@ -0,0 +1,25 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Aggregate functions for processed history reads.
+///
+public enum AggregateType
+{
+ /// Average of values in the interval.
+ Average,
+
+ /// Minimum value in the interval.
+ Minimum,
+
+ /// Maximum value in the interval.
+ Maximum,
+
+ /// Count of values in the interval.
+ Count,
+
+ /// First value in the interval.
+ Start,
+
+ /// Last value in the interval.
+ End
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs
new file mode 100644
index 0000000..0938bea
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs
@@ -0,0 +1,51 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Event data for an alarm or condition notification from the OPC UA server.
+///
+public sealed class AlarmEventArgs : EventArgs
+{
+ /// The name of the source object that raised the alarm.
+ public string SourceName { get; }
+
+ /// The condition type name.
+ public string ConditionName { get; }
+
+ /// The alarm severity (0-1000).
+ public ushort Severity { get; }
+
+ /// Human-readable alarm message.
+ public string Message { get; }
+
+ /// Whether the alarm should be retained in the display.
+ public bool Retain { get; }
+
+ /// Whether the alarm condition is currently active.
+ public bool ActiveState { get; }
+
+ /// Whether the alarm has been acknowledged.
+ public bool AckedState { get; }
+
+ /// The time the event occurred.
+ public DateTime Time { get; }
+
+ public AlarmEventArgs(
+ string sourceName,
+ string conditionName,
+ ushort severity,
+ string message,
+ bool retain,
+ bool activeState,
+ bool ackedState,
+ DateTime time)
+ {
+ SourceName = sourceName;
+ ConditionName = conditionName;
+ Severity = severity;
+ Message = message;
+ Retain = retain;
+ ActiveState = activeState;
+ AckedState = ackedState;
+ Time = time;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs
new file mode 100644
index 0000000..c1694a8
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs
@@ -0,0 +1,35 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Represents a single node in the browse result set.
+///
+public sealed class BrowseResult
+{
+ ///
+ /// The string representation of the node's NodeId.
+ ///
+ public string NodeId { get; }
+
+ ///
+ /// The display name of the node.
+ ///
+ public string DisplayName { get; }
+
+ ///
+ /// The node class (e.g., "Object", "Variable", "Method").
+ ///
+ public string NodeClass { get; }
+
+ ///
+ /// Whether the node has child references.
+ ///
+ public bool HasChildren { get; }
+
+ public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren)
+ {
+ NodeId = nodeId;
+ DisplayName = displayName;
+ NodeClass = nodeClass;
+ HasChildren = hasChildren;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs
new file mode 100644
index 0000000..8e01606
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs
@@ -0,0 +1,41 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Information about the current OPC UA session.
+///
+public sealed class ConnectionInfo
+{
+ /// The endpoint URL of the connected server.
+ public string EndpointUrl { get; }
+
+ /// The server application name.
+ public string ServerName { get; }
+
+ /// The security mode in use (e.g., "None", "Sign", "SignAndEncrypt").
+ public string SecurityMode { get; }
+
+ /// The security policy URI (e.g., "http://opcfoundation.org/UA/SecurityPolicy#None").
+ public string SecurityPolicyUri { get; }
+
+ /// The session identifier.
+ public string SessionId { get; }
+
+ /// The session name.
+ public string SessionName { get; }
+
+ public ConnectionInfo(
+ string endpointUrl,
+ string serverName,
+ string securityMode,
+ string securityPolicyUri,
+ string sessionId,
+ string sessionName)
+ {
+ EndpointUrl = endpointUrl;
+ ServerName = serverName;
+ SecurityMode = securityMode;
+ SecurityPolicyUri = securityPolicyUri;
+ SessionId = sessionId;
+ SessionName = sessionName;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs
new file mode 100644
index 0000000..f6e15c3
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs
@@ -0,0 +1,65 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Settings for establishing an OPC UA client connection.
+///
+public sealed class ConnectionSettings
+{
+ ///
+ /// The primary OPC UA endpoint URL.
+ ///
+ public string EndpointUrl { get; set; } = string.Empty;
+
+ ///
+ /// Optional failover endpoint URLs for redundancy.
+ ///
+ public string[]? FailoverUrls { get; set; }
+
+ ///
+ /// Optional username for authentication.
+ ///
+ public string? Username { get; set; }
+
+ ///
+ /// Optional password for authentication.
+ ///
+ public string? Password { get; set; }
+
+ ///
+ /// Transport security mode. Defaults to .
+ ///
+ public SecurityMode SecurityMode { get; set; } = SecurityMode.None;
+
+ ///
+ /// Session timeout in seconds. Defaults to 60.
+ ///
+ public int SessionTimeoutSeconds { get; set; } = 60;
+
+ ///
+ /// Whether to automatically accept untrusted server certificates. Defaults to true.
+ ///
+ public bool AutoAcceptCertificates { get; set; } = true;
+
+ ///
+ /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData.
+ ///
+ public string CertificateStorePath { get; set; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "LmxOpcUaClient", "pki");
+
+ ///
+ /// Validates the settings and throws if any required values are missing or invalid.
+ ///
+ /// Thrown when settings are invalid.
+ public void Validate()
+ {
+ if (string.IsNullOrWhiteSpace(EndpointUrl))
+ throw new ArgumentException("EndpointUrl must not be null or empty.", nameof(EndpointUrl));
+
+ if (SessionTimeoutSeconds <= 0)
+ throw new ArgumentException("SessionTimeoutSeconds must be greater than zero.", nameof(SessionTimeoutSeconds));
+
+ if (SessionTimeoutSeconds > 3600)
+ throw new ArgumentException("SessionTimeoutSeconds must not exceed 3600.", nameof(SessionTimeoutSeconds));
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs
new file mode 100644
index 0000000..bdf6158
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs
@@ -0,0 +1,19 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Represents the current state of the OPC UA client connection.
+///
+public enum ConnectionState
+{
+ /// Not connected to any server.
+ Disconnected,
+
+ /// Connection attempt is in progress.
+ Connecting,
+
+ /// Successfully connected to a server.
+ Connected,
+
+ /// Connection was lost and reconnection is in progress.
+ Reconnecting
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs
new file mode 100644
index 0000000..6645e7b
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs
@@ -0,0 +1,23 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Event data raised when the client connection state changes.
+///
+public sealed class ConnectionStateChangedEventArgs : EventArgs
+{
+ /// The previous connection state.
+ public ConnectionState OldState { get; }
+
+ /// The new connection state.
+ public ConnectionState NewState { get; }
+
+ /// The endpoint URL associated with the state change.
+ public string EndpointUrl { get; }
+
+ public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl)
+ {
+ OldState = oldState;
+ NewState = newState;
+ EndpointUrl = endpointUrl;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs
new file mode 100644
index 0000000..cd3aeb8
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs
@@ -0,0 +1,21 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Event data for a monitored data value change.
+///
+public sealed class DataChangedEventArgs : EventArgs
+{
+ /// The string representation of the node that changed.
+ public string NodeId { get; }
+
+ /// The new data value from the server.
+ public DataValue Value { get; }
+
+ public DataChangedEventArgs(string nodeId, DataValue value)
+ {
+ NodeId = nodeId;
+ Value = value;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs
new file mode 100644
index 0000000..a718ed2
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs
@@ -0,0 +1,27 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Redundancy information read from the server.
+///
+public sealed class RedundancyInfo
+{
+ /// The redundancy mode (e.g., "None", "Cold", "Warm", "Hot").
+ public string Mode { get; }
+
+ /// The server's current service level (0-255).
+ public byte ServiceLevel { get; }
+
+ /// URIs of all servers in the redundant set.
+ public string[] ServerUris { get; }
+
+ /// The application URI of the connected server.
+ public string ApplicationUri { get; }
+
+ public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri)
+ {
+ Mode = mode;
+ ServiceLevel = serviceLevel;
+ ServerUris = serverUris;
+ ApplicationUri = applicationUri;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs
new file mode 100644
index 0000000..4313ec0
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs
@@ -0,0 +1,16 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+///
+/// Transport security mode for the OPC UA connection.
+///
+public enum SecurityMode
+{
+ /// No transport security.
+ None,
+
+ /// Messages are signed but not encrypted.
+ Sign,
+
+ /// Messages are signed and encrypted.
+ SignAndEncrypt
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs
new file mode 100644
index 0000000..b399111
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs
@@ -0,0 +1,572 @@
+using Opc.Ua;
+using Serilog;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+///
+/// Full implementation of using adapter abstractions for testability.
+///
+public sealed class OpcUaClientService : IOpcUaClientService
+{
+ private static readonly ILogger Logger = Log.ForContext();
+
+ private readonly IApplicationConfigurationFactory _configFactory;
+ private readonly IEndpointDiscovery _endpointDiscovery;
+ private readonly ISessionFactory _sessionFactory;
+
+ private ISessionAdapter? _session;
+ private ISubscriptionAdapter? _dataSubscription;
+ private ISubscriptionAdapter? _alarmSubscription;
+ private ConnectionState _state = ConnectionState.Disconnected;
+ private ConnectionSettings? _settings;
+ private string[]? _allEndpointUrls;
+ private int _currentEndpointIndex;
+ private bool _disposed;
+
+ // Track active data subscriptions for replay after failover
+ private readonly Dictionary _activeDataSubscriptions = new();
+ // Track alarm subscription state for replay after failover
+ private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription;
+
+ public event EventHandler? DataChanged;
+ public event EventHandler? AlarmEvent;
+ public event EventHandler? ConnectionStateChanged;
+
+ public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;
+ public ConnectionInfo? CurrentConnectionInfo { get; private set; }
+
+ ///
+ /// Creates a new OpcUaClientService with the specified adapter dependencies.
+ ///
+ internal OpcUaClientService(
+ IApplicationConfigurationFactory configFactory,
+ IEndpointDiscovery endpointDiscovery,
+ ISessionFactory sessionFactory)
+ {
+ _configFactory = configFactory;
+ _endpointDiscovery = endpointDiscovery;
+ _sessionFactory = sessionFactory;
+ }
+
+ ///
+ /// Creates a new OpcUaClientService with default production adapters.
+ ///
+ public OpcUaClientService()
+ : this(
+ new DefaultApplicationConfigurationFactory(),
+ new DefaultEndpointDiscovery(),
+ new DefaultSessionFactory())
+ {
+ }
+
+ public async Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ settings.Validate();
+
+ _settings = settings;
+ _allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
+ _currentEndpointIndex = 0;
+
+ TransitionState(ConnectionState.Connecting, settings.EndpointUrl);
+
+ try
+ {
+ var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct);
+ _session = session;
+
+ session.RegisterKeepAliveHandler(isGood =>
+ {
+ if (!isGood)
+ {
+ _ = HandleKeepAliveFailureAsync();
+ }
+ });
+
+ CurrentConnectionInfo = BuildConnectionInfo(session);
+ TransitionState(ConnectionState.Connected, session.EndpointUrl);
+ Logger.Information("Connected to {EndpointUrl}", session.EndpointUrl);
+ return CurrentConnectionInfo;
+ }
+ catch
+ {
+ TransitionState(ConnectionState.Disconnected, settings.EndpointUrl);
+ throw;
+ }
+ }
+
+ public async Task DisconnectAsync(CancellationToken ct = default)
+ {
+ if (_state == ConnectionState.Disconnected)
+ return;
+
+ var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
+
+ try
+ {
+ if (_dataSubscription != null)
+ {
+ await _dataSubscription.DeleteAsync(ct);
+ _dataSubscription = null;
+ }
+ if (_alarmSubscription != null)
+ {
+ await _alarmSubscription.DeleteAsync(ct);
+ _alarmSubscription = null;
+ }
+ if (_session != null)
+ {
+ await _session.CloseAsync(ct);
+ _session.Dispose();
+ _session = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "Error during disconnect");
+ }
+ finally
+ {
+ _activeDataSubscriptions.Clear();
+ _activeAlarmSubscription = null;
+ CurrentConnectionInfo = null;
+ TransitionState(ConnectionState.Disconnected, endpointUrl);
+ }
+ }
+
+ public async Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+ return await _session!.ReadValueAsync(nodeId, ct);
+ }
+
+ public async Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+
+ // Read current value for type coercion when value is a string
+ object typedValue = value;
+ if (value is string rawString)
+ {
+ var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
+ typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
+ }
+
+ var dataValue = new DataValue(new Variant(typedValue));
+ return await _session!.WriteValueAsync(nodeId, dataValue, ct);
+ }
+
+ public async Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+
+ var startNode = parentNodeId ?? ObjectIds.ObjectsFolder;
+ var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
+ var results = new List();
+
+ var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
+
+ while (references.Count > 0)
+ {
+ foreach (var reference in references)
+ {
+ var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
+ var hasChildren = reference.NodeClass == NodeClass.Object &&
+ await _session.HasChildrenAsync(childNodeId, ct);
+
+ results.Add(new Models.BrowseResult(
+ reference.NodeId.ToString(),
+ reference.DisplayName?.Text ?? string.Empty,
+ reference.NodeClass.ToString(),
+ hasChildren));
+ }
+
+ if (continuationPoint != null && continuationPoint.Length > 0)
+ {
+ (continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ return results;
+ }
+
+ public async Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+
+ var nodeIdStr = nodeId.ToString();
+ if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
+ return; // Already subscribed
+
+ if (_dataSubscription == null)
+ {
+ _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
+ }
+
+ var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
+ nodeId, intervalMs, OnDataChangeNotification, ct);
+
+ _activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
+ Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
+ }
+
+ public async Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+
+ var nodeIdStr = nodeId.ToString();
+ if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out var sub))
+ return; // Not subscribed, safe to ignore
+
+ if (_dataSubscription != null)
+ {
+ await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
+ }
+
+ _activeDataSubscriptions.Remove(nodeIdStr);
+ Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
+ }
+
+ public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+
+ if (_alarmSubscription != null)
+ return; // Already subscribed to alarms
+
+ var monitorNode = sourceNodeId ?? ObjectIds.Server;
+ _alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
+
+ var filter = CreateAlarmEventFilter();
+ await _alarmSubscription.AddEventMonitoredItemAsync(
+ monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
+
+ _activeAlarmSubscription = (sourceNodeId, intervalMs);
+ Logger.Debug("Subscribed to alarm events on {NodeId}", monitorNode);
+ }
+
+ public async Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+
+ if (_alarmSubscription == null)
+ return;
+
+ await _alarmSubscription.DeleteAsync(ct);
+ _alarmSubscription = null;
+ _activeAlarmSubscription = null;
+ Logger.Debug("Unsubscribed from alarm events");
+ }
+
+ public async Task RequestConditionRefreshAsync(CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+
+ if (_alarmSubscription == null)
+ throw new InvalidOperationException("No alarm subscription is active.");
+
+ await _alarmSubscription.ConditionRefreshAsync(ct);
+ Logger.Debug("Condition refresh requested");
+ }
+
+ public async Task> HistoryReadRawAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+ return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
+ }
+
+ public async Task> HistoryReadAggregateAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
+ double intervalMs = 3600000, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+ var aggregateNodeId = AggregateTypeMapper.ToNodeId(aggregate);
+ return await _session!.HistoryReadAggregateAsync(nodeId, startTime, endTime, aggregateNodeId, intervalMs, ct);
+ }
+
+ public async Task GetRedundancyInfoAsync(CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+
+ var redundancySupportValue = await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
+ var redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString();
+
+ var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
+ var serviceLevel = (byte)serviceLevelValue.Value;
+
+ string[] serverUris = Array.Empty();
+ try
+ {
+ var serverUriArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
+ if (serverUriArrayValue.Value is string[] uris)
+ serverUris = uris;
+ }
+ catch
+ {
+ // ServerUriArray may not be present when RedundancySupport is None
+ }
+
+ string applicationUri = string.Empty;
+ try
+ {
+ var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
+ if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
+ applicationUri = serverArray[0];
+ }
+ catch
+ {
+ // Informational only
+ }
+
+ return new RedundancyInfo(redundancyMode, serviceLevel, serverUris, applicationUri);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _dataSubscription?.Dispose();
+ _alarmSubscription?.Dispose();
+ _session?.Dispose();
+ _activeDataSubscriptions.Clear();
+ _activeAlarmSubscription = null;
+ CurrentConnectionInfo = null;
+ _state = ConnectionState.Disconnected;
+ }
+
+ // --- Private helpers ---
+
+ private async Task ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl, CancellationToken ct)
+ {
+ // Create a settings copy with the current endpoint URL
+ var effectiveSettings = new ConnectionSettings
+ {
+ EndpointUrl = endpointUrl,
+ SecurityMode = settings.SecurityMode,
+ SessionTimeoutSeconds = settings.SessionTimeoutSeconds,
+ AutoAcceptCertificates = settings.AutoAcceptCertificates,
+ CertificateStorePath = settings.CertificateStorePath,
+ Username = settings.Username,
+ Password = settings.Password
+ };
+
+ var config = await _configFactory.CreateAsync(effectiveSettings, ct);
+ var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
+ var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
+
+ UserIdentity identity = settings.Username != null
+ ? new UserIdentity(settings.Username, System.Text.Encoding.UTF8.GetBytes(settings.Password ?? ""))
+ : new UserIdentity();
+
+ var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
+ return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity, ct);
+ }
+
+ private async Task HandleKeepAliveFailureAsync()
+ {
+ if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
+ return;
+
+ var oldEndpoint = _session?.EndpointUrl ?? string.Empty;
+ TransitionState(ConnectionState.Reconnecting, oldEndpoint);
+ Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
+
+ // Close old session
+ if (_session != null)
+ {
+ try { _session.Dispose(); } catch { }
+ _session = null;
+ }
+ _dataSubscription = null;
+ _alarmSubscription = null;
+
+ if (_settings == null || _allEndpointUrls == null)
+ {
+ TransitionState(ConnectionState.Disconnected, oldEndpoint);
+ return;
+ }
+
+ // Try each endpoint
+ for (int attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
+ {
+ _currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
+ var url = _allEndpointUrls[_currentEndpointIndex];
+
+ try
+ {
+ Logger.Information("Failover attempt to {EndpointUrl}", url);
+ var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
+ _session = session;
+
+ session.RegisterKeepAliveHandler(isGood =>
+ {
+ if (!isGood) { _ = HandleKeepAliveFailureAsync(); }
+ });
+
+ CurrentConnectionInfo = BuildConnectionInfo(session);
+ TransitionState(ConnectionState.Connected, url);
+ Logger.Information("Failover succeeded to {EndpointUrl}", url);
+
+ // Replay subscriptions
+ await ReplaySubscriptionsAsync();
+ return;
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
+ }
+ }
+
+ Logger.Error("All failover endpoints unreachable");
+ TransitionState(ConnectionState.Disconnected, oldEndpoint);
+ }
+
+ private async Task ReplaySubscriptionsAsync()
+ {
+ // Replay data subscriptions
+ if (_activeDataSubscriptions.Count > 0)
+ {
+ var subscriptions = _activeDataSubscriptions.ToList();
+ _activeDataSubscriptions.Clear();
+
+ foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
+ {
+ try
+ {
+ if (_dataSubscription == null)
+ _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
+
+ var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
+ nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
+ _activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
+ }
+ }
+ }
+
+ // Replay alarm subscription
+ if (_activeAlarmSubscription.HasValue)
+ {
+ var (sourceNodeId, intervalMs) = _activeAlarmSubscription.Value;
+ _activeAlarmSubscription = null;
+ try
+ {
+ var monitorNode = sourceNodeId ?? ObjectIds.Server;
+ _alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
+ var filter = CreateAlarmEventFilter();
+ await _alarmSubscription.AddEventMonitoredItemAsync(
+ monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
+ _activeAlarmSubscription = (sourceNodeId, intervalMs);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "Failed to replay alarm subscription");
+ }
+ }
+ }
+
+ private void OnDataChangeNotification(string nodeId, DataValue value)
+ {
+ DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
+ }
+
+ private void OnAlarmEventNotification(EventFieldList eventFields)
+ {
+ var fields = eventFields.EventFields;
+ if (fields == null || fields.Count < 6)
+ return;
+
+ var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
+ var time = fields.Count > 3 ? fields[3].Value as DateTime? ?? DateTime.MinValue : DateTime.MinValue;
+ var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
+ var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
+ var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
+ var retain = fields.Count > 7 ? fields[7].Value as bool? ?? false : false;
+ var ackedState = fields.Count > 8 ? fields[8].Value as bool? ?? false : false;
+ var activeState = fields.Count > 9 ? fields[9].Value as bool? ?? false : false;
+
+ AlarmEvent?.Invoke(this, new AlarmEventArgs(
+ sourceName, conditionName, severity, message, retain, activeState, ackedState, time));
+ }
+
+ private static EventFilter CreateAlarmEventFilter()
+ {
+ var filter = new EventFilter();
+ // 0: EventId
+ filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
+ // 1: EventType
+ filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
+ // 2: SourceName
+ filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
+ // 3: Time
+ filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
+ // 4: Message
+ filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
+ // 5: Severity
+ filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
+ // 6: ConditionName
+ filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
+ // 7: Retain
+ filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
+ // 8: AckedState/Id
+ filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id");
+ // 9: ActiveState/Id
+ filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id");
+ // 10: EnabledState/Id
+ filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
+ // 11: SuppressedOrShelved
+ filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
+ return filter;
+ }
+
+ private static ConnectionInfo BuildConnectionInfo(ISessionAdapter session)
+ {
+ return new ConnectionInfo(
+ session.EndpointUrl,
+ session.ServerName,
+ session.SecurityMode,
+ session.SecurityPolicyUri,
+ session.SessionId,
+ session.SessionName);
+ }
+
+ private void TransitionState(ConnectionState newState, string endpointUrl)
+ {
+ var oldState = _state;
+ if (oldState == newState) return;
+ _state = newState;
+ ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(OpcUaClientService));
+ }
+
+ private void ThrowIfNotConnected()
+ {
+ if (_state != ConnectionState.Connected || _session == null)
+ throw new InvalidOperationException("Not connected to an OPC UA server.");
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs
new file mode 100644
index 0000000..5f0d52d
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+///
+/// Default factory that creates instances with production adapters.
+///
+public sealed class OpcUaClientServiceFactory : IOpcUaClientServiceFactory
+{
+ public IOpcUaClientService Create()
+ {
+ return new OpcUaClientService();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj
new file mode 100644
index 0000000..176672f
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ZB.MOM.WW.LmxOpcUa.Client.Shared
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml
new file mode 100644
index 0000000..888cfec
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs
new file mode 100644
index 0000000..a0f2e30
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs
@@ -0,0 +1,33 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var factory = new OpcUaClientServiceFactory();
+ var dispatcher = new AvaloniaUiDispatcher();
+ var viewModel = new MainWindowViewModel(factory, dispatcher);
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = viewModel
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs
new file mode 100644
index 0000000..419b9b3
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs
@@ -0,0 +1,16 @@
+using Avalonia;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
+
+class Program
+{
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs
new file mode 100644
index 0000000..e4cc9d8
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs
@@ -0,0 +1,14 @@
+using Avalonia.Threading;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+///
+/// Dispatches actions to the Avalonia UI thread.
+///
+public sealed class AvaloniaUiDispatcher : IUiDispatcher
+{
+ public void Post(Action action)
+ {
+ Dispatcher.UIThread.Post(action);
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs
new file mode 100644
index 0000000..6f5b3e5
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+///
+/// Abstraction for dispatching actions to the UI thread.
+///
+public interface IUiDispatcher
+{
+ ///
+ /// Posts an action to be executed on the UI thread.
+ ///
+ void Post(Action action);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs
new file mode 100644
index 0000000..fd253ee
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs
@@ -0,0 +1,13 @@
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+///
+/// Dispatcher that executes actions synchronously on the calling thread.
+/// Used for unit testing where no UI thread is available.
+///
+public sealed class SynchronousUiDispatcher : IUiDispatcher
+{
+ public void Post(Action action)
+ {
+ action();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs
new file mode 100644
index 0000000..97f11d3
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs
@@ -0,0 +1,38 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// Represents a single alarm event row.
+///
+public partial class AlarmEventViewModel : ObservableObject
+{
+ public string SourceName { get; }
+ public string ConditionName { get; }
+ public ushort Severity { get; }
+ public string Message { get; }
+ public bool Retain { get; }
+ public bool ActiveState { get; }
+ public bool AckedState { get; }
+ public DateTime Time { get; }
+
+ public AlarmEventViewModel(
+ string sourceName,
+ string conditionName,
+ ushort severity,
+ string message,
+ bool retain,
+ bool activeState,
+ bool ackedState,
+ DateTime time)
+ {
+ SourceName = sourceName;
+ ConditionName = conditionName;
+ Severity = severity;
+ Message = message;
+ Retain = retain;
+ ActiveState = activeState;
+ AckedState = ackedState;
+ Time = time;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs
new file mode 100644
index 0000000..6c73bc3
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs
@@ -0,0 +1,128 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// ViewModel for the alarms panel.
+///
+public partial class AlarmsViewModel : ObservableObject
+{
+ private readonly IOpcUaClientService _service;
+ private readonly IUiDispatcher _dispatcher;
+
+ /// Received alarm events.
+ public ObservableCollection AlarmEvents { get; } = new();
+
+ [ObservableProperty]
+ private string? _monitoredNodeIdText;
+
+ [ObservableProperty]
+ private int _interval = 1000;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
+ [NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
+ private bool _isSubscribed;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
+ [NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
+ private bool _isConnected;
+
+ public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
+ {
+ _service = service;
+ _dispatcher = dispatcher;
+ _service.AlarmEvent += OnAlarmEvent;
+ }
+
+ private void OnAlarmEvent(object? sender, AlarmEventArgs e)
+ {
+ _dispatcher.Post(() =>
+ {
+ AlarmEvents.Add(new AlarmEventViewModel(
+ e.SourceName,
+ e.ConditionName,
+ e.Severity,
+ e.Message,
+ e.Retain,
+ e.ActiveState,
+ e.AckedState,
+ e.Time));
+ });
+ }
+
+ private bool CanSubscribe() => IsConnected && !IsSubscribed;
+
+ [RelayCommand(CanExecute = nameof(CanSubscribe))]
+ private async Task SubscribeAsync()
+ {
+ try
+ {
+ NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
+ ? null
+ : NodeId.Parse(MonitoredNodeIdText);
+
+ await _service.SubscribeAlarmsAsync(sourceNodeId, Interval);
+ IsSubscribed = true;
+ }
+ catch
+ {
+ // Subscribe failed
+ }
+ }
+
+ private bool CanUnsubscribe() => IsConnected && IsSubscribed;
+
+ [RelayCommand(CanExecute = nameof(CanUnsubscribe))]
+ private async Task UnsubscribeAsync()
+ {
+ try
+ {
+ await _service.UnsubscribeAlarmsAsync();
+ IsSubscribed = false;
+ }
+ catch
+ {
+ // Unsubscribe failed
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanUnsubscribe))]
+ private async Task RefreshAsync()
+ {
+ try
+ {
+ await _service.RequestConditionRefreshAsync();
+ }
+ catch
+ {
+ // Refresh failed
+ }
+ }
+
+ ///
+ /// Clears alarm events and resets state.
+ ///
+ public void Clear()
+ {
+ AlarmEvents.Clear();
+ IsSubscribed = false;
+ }
+
+ ///
+ /// Unhooks event handlers from the service.
+ ///
+ public void Teardown()
+ {
+ _service.AlarmEvent -= OnAlarmEvent;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs
new file mode 100644
index 0000000..f1875ee
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs
@@ -0,0 +1,55 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// ViewModel for the OPC UA browse tree panel.
+///
+public partial class BrowseTreeViewModel : ObservableObject
+{
+ private readonly IOpcUaClientService _service;
+ private readonly IUiDispatcher _dispatcher;
+
+ /// Top-level nodes in the browse tree.
+ public ObservableCollection RootNodes { get; } = new();
+
+ public BrowseTreeViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
+ {
+ _service = service;
+ _dispatcher = dispatcher;
+ }
+
+ ///
+ /// Loads root nodes by browsing with a null parent.
+ ///
+ public async Task LoadRootsAsync()
+ {
+ var results = await _service.BrowseAsync(null);
+
+ _dispatcher.Post(() =>
+ {
+ RootNodes.Clear();
+ foreach (var result in results)
+ {
+ RootNodes.Add(new TreeNodeViewModel(
+ result.NodeId,
+ result.DisplayName,
+ result.NodeClass,
+ result.HasChildren,
+ _service,
+ _dispatcher));
+ }
+ });
+ }
+
+ ///
+ /// Clears all root nodes from the tree.
+ ///
+ public void Clear()
+ {
+ RootNodes.Clear();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs
new file mode 100644
index 0000000..ac7ad0f
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs
@@ -0,0 +1,22 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// Represents a single historical value row.
+///
+public partial class HistoryValueViewModel : ObservableObject
+{
+ public string Value { get; }
+ public string Status { get; }
+ public string SourceTimestamp { get; }
+ public string ServerTimestamp { get; }
+
+ public HistoryValueViewModel(string value, string status, string sourceTimestamp, string serverTimestamp)
+ {
+ Value = value;
+ Status = status;
+ SourceTimestamp = sourceTimestamp;
+ ServerTimestamp = serverTimestamp;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
new file mode 100644
index 0000000..233a53f
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
@@ -0,0 +1,140 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// ViewModel for the history panel.
+///
+public partial class HistoryViewModel : ObservableObject
+{
+ private readonly IOpcUaClientService _service;
+ private readonly IUiDispatcher _dispatcher;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
+ private string? _selectedNodeId;
+
+ [ObservableProperty]
+ private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1);
+
+ [ObservableProperty]
+ private DateTimeOffset _endTime = DateTimeOffset.UtcNow;
+
+ [ObservableProperty]
+ private int _maxValues = 1000;
+
+ [ObservableProperty]
+ private AggregateType? _selectedAggregateType;
+
+ /// Available aggregate types (null means "Raw").
+ public IReadOnlyList AggregateTypes { get; } = new AggregateType?[]
+ {
+ null,
+ AggregateType.Average,
+ AggregateType.Minimum,
+ AggregateType.Maximum,
+ AggregateType.Count,
+ AggregateType.Start,
+ AggregateType.End
+ };
+
+ [ObservableProperty]
+ private double _intervalMs = 3600000;
+
+ public bool IsAggregateRead => SelectedAggregateType != null;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
+ private bool _isConnected;
+
+ /// History read results.
+ public ObservableCollection Results { get; } = new();
+
+ public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
+ {
+ _service = service;
+ _dispatcher = dispatcher;
+ }
+
+ partial void OnSelectedAggregateTypeChanged(AggregateType? value)
+ {
+ OnPropertyChanged(nameof(IsAggregateRead));
+ }
+
+ private bool CanReadHistory() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
+
+ [RelayCommand(CanExecute = nameof(CanReadHistory))]
+ private async Task ReadHistoryAsync()
+ {
+ if (string.IsNullOrEmpty(SelectedNodeId)) return;
+
+ IsLoading = true;
+ _dispatcher.Post(() => Results.Clear());
+
+ try
+ {
+ var nodeId = NodeId.Parse(SelectedNodeId);
+ IReadOnlyList values;
+
+ if (SelectedAggregateType != null)
+ {
+ values = await _service.HistoryReadAggregateAsync(
+ nodeId,
+ StartTime.UtcDateTime,
+ EndTime.UtcDateTime,
+ SelectedAggregateType.Value,
+ IntervalMs);
+ }
+ else
+ {
+ values = await _service.HistoryReadRawAsync(
+ nodeId,
+ StartTime.UtcDateTime,
+ EndTime.UtcDateTime,
+ MaxValues);
+ }
+
+ _dispatcher.Post(() =>
+ {
+ foreach (var dv in values)
+ {
+ Results.Add(new HistoryValueViewModel(
+ dv.Value?.ToString() ?? "(null)",
+ dv.StatusCode.ToString(),
+ dv.SourceTimestamp.ToString("O"),
+ dv.ServerTimestamp.ToString("O")));
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ _dispatcher.Post(() =>
+ {
+ Results.Add(new HistoryValueViewModel(
+ $"Error: {ex.Message}", string.Empty, string.Empty, string.Empty));
+ });
+ }
+ finally
+ {
+ _dispatcher.Post(() => IsLoading = false);
+ }
+ }
+
+ ///
+ /// Clears results and resets state.
+ ///
+ public void Clear()
+ {
+ Results.Clear();
+ SelectedNodeId = null;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..9d5a684
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,198 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// Main window ViewModel coordinating all panels.
+///
+public partial class MainWindowViewModel : ObservableObject
+{
+ private readonly IOpcUaClientService _service;
+ private readonly IUiDispatcher _dispatcher;
+
+ [ObservableProperty]
+ private string _endpointUrl = "opc.tcp://localhost:4840";
+
+ [ObservableProperty]
+ private string? _username;
+
+ [ObservableProperty]
+ private string? _password;
+
+ [ObservableProperty]
+ private SecurityMode _selectedSecurityMode = SecurityMode.None;
+
+ /// All available security modes.
+ public IReadOnlyList SecurityModes { get; } = Enum.GetValues();
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DisconnectCommand))]
+ private ConnectionState _connectionState = ConnectionState.Disconnected;
+
+ public bool IsConnected => ConnectionState == ConnectionState.Connected;
+
+ [ObservableProperty]
+ private TreeNodeViewModel? _selectedTreeNode;
+
+ [ObservableProperty]
+ private RedundancyInfo? _redundancyInfo;
+
+ [ObservableProperty]
+ private string _statusMessage = "Disconnected";
+
+ [ObservableProperty]
+ private string _sessionLabel = string.Empty;
+
+ [ObservableProperty]
+ private int _subscriptionCount;
+
+ public BrowseTreeViewModel BrowseTree { get; }
+ public ReadWriteViewModel ReadWrite { get; }
+ public SubscriptionsViewModel Subscriptions { get; }
+ public AlarmsViewModel Alarms { get; }
+ public HistoryViewModel History { get; }
+
+ public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher)
+ {
+ _service = factory.Create();
+ _dispatcher = dispatcher;
+
+ BrowseTree = new BrowseTreeViewModel(_service, dispatcher);
+ ReadWrite = new ReadWriteViewModel(_service, dispatcher);
+ Subscriptions = new SubscriptionsViewModel(_service, dispatcher);
+ Alarms = new AlarmsViewModel(_service, dispatcher);
+ History = new HistoryViewModel(_service, dispatcher);
+
+ _service.ConnectionStateChanged += OnConnectionStateChanged;
+ }
+
+ private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
+ {
+ _dispatcher.Post(() =>
+ {
+ ConnectionState = e.NewState;
+ });
+ }
+
+ partial void OnConnectionStateChanged(ConnectionState value)
+ {
+ OnPropertyChanged(nameof(IsConnected));
+
+ var connected = value == ConnectionState.Connected;
+ ReadWrite.IsConnected = connected;
+ Subscriptions.IsConnected = connected;
+ Alarms.IsConnected = connected;
+ History.IsConnected = connected;
+
+ switch (value)
+ {
+ case ConnectionState.Connected:
+ StatusMessage = $"Connected to {EndpointUrl}";
+ break;
+ case ConnectionState.Reconnecting:
+ StatusMessage = "Reconnecting...";
+ break;
+ case ConnectionState.Connecting:
+ StatusMessage = "Connecting...";
+ break;
+ case ConnectionState.Disconnected:
+ StatusMessage = "Disconnected";
+ SessionLabel = string.Empty;
+ RedundancyInfo = null;
+ BrowseTree.Clear();
+ ReadWrite.Clear();
+ Subscriptions.Clear();
+ Alarms.Clear();
+ History.Clear();
+ SubscriptionCount = 0;
+ break;
+ }
+ }
+
+ partial void OnSelectedTreeNodeChanged(TreeNodeViewModel? value)
+ {
+ ReadWrite.SelectedNodeId = value?.NodeId;
+ History.SelectedNodeId = value?.NodeId;
+ }
+
+ private bool CanConnect() => ConnectionState == ConnectionState.Disconnected;
+
+ [RelayCommand(CanExecute = nameof(CanConnect))]
+ private async Task ConnectAsync()
+ {
+ try
+ {
+ ConnectionState = ConnectionState.Connecting;
+ StatusMessage = "Connecting...";
+
+ var settings = new ConnectionSettings
+ {
+ EndpointUrl = EndpointUrl,
+ Username = Username,
+ Password = Password,
+ SecurityMode = SelectedSecurityMode
+ };
+ settings.Validate();
+
+ var info = await _service.ConnectAsync(settings);
+
+ _dispatcher.Post(() =>
+ {
+ ConnectionState = ConnectionState.Connected;
+ SessionLabel = $"{info.ServerName} | Session: {info.SessionName} ({info.SessionId})";
+ });
+
+ // Load redundancy info
+ try
+ {
+ var redundancy = await _service.GetRedundancyInfoAsync();
+ _dispatcher.Post(() => RedundancyInfo = redundancy);
+ }
+ catch
+ {
+ // Redundancy info not available
+ }
+
+ // Load root nodes
+ await BrowseTree.LoadRootsAsync();
+ }
+ catch (Exception ex)
+ {
+ _dispatcher.Post(() =>
+ {
+ ConnectionState = ConnectionState.Disconnected;
+ StatusMessage = $"Connection failed: {ex.Message}";
+ });
+ }
+ }
+
+ private bool CanDisconnect() => ConnectionState == ConnectionState.Connected
+ || ConnectionState == ConnectionState.Reconnecting;
+
+ [RelayCommand(CanExecute = nameof(CanDisconnect))]
+ private async Task DisconnectAsync()
+ {
+ try
+ {
+ Subscriptions.Teardown();
+ Alarms.Teardown();
+ await _service.DisconnectAsync();
+ }
+ catch
+ {
+ // Best-effort disconnect
+ }
+ finally
+ {
+ _dispatcher.Post(() =>
+ {
+ ConnectionState = ConnectionState.Disconnected;
+ });
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs
new file mode 100644
index 0000000..4eb2c6c
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs
@@ -0,0 +1,136 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// ViewModel for the read/write panel.
+///
+public partial class ReadWriteViewModel : ObservableObject
+{
+ private readonly IOpcUaClientService _service;
+ private readonly IUiDispatcher _dispatcher;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ReadCommand))]
+ [NotifyCanExecuteChangedFor(nameof(WriteCommand))]
+ private string? _selectedNodeId;
+
+ [ObservableProperty]
+ private string? _currentValue;
+
+ [ObservableProperty]
+ private string? _currentStatus;
+
+ [ObservableProperty]
+ private string? _sourceTimestamp;
+
+ [ObservableProperty]
+ private string? _serverTimestamp;
+
+ [ObservableProperty]
+ private string? _writeValue;
+
+ [ObservableProperty]
+ private string? _writeStatus;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ReadCommand))]
+ [NotifyCanExecuteChangedFor(nameof(WriteCommand))]
+ private bool _isConnected;
+
+ public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId);
+
+ public ReadWriteViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
+ {
+ _service = service;
+ _dispatcher = dispatcher;
+ }
+
+ partial void OnSelectedNodeIdChanged(string? value)
+ {
+ OnPropertyChanged(nameof(IsNodeSelected));
+ if (!string.IsNullOrEmpty(value) && IsConnected)
+ {
+ _ = ExecuteReadAsync();
+ }
+ }
+
+ private bool CanReadOrWrite() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
+
+ [RelayCommand(CanExecute = nameof(CanReadOrWrite))]
+ private async Task ReadAsync()
+ {
+ await ExecuteReadAsync();
+ }
+
+ private async Task ExecuteReadAsync()
+ {
+ if (string.IsNullOrEmpty(SelectedNodeId)) return;
+
+ try
+ {
+ var nodeId = NodeId.Parse(SelectedNodeId);
+ var dataValue = await _service.ReadValueAsync(nodeId);
+
+ _dispatcher.Post(() =>
+ {
+ CurrentValue = dataValue.Value?.ToString() ?? "(null)";
+ CurrentStatus = dataValue.StatusCode.ToString();
+ SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
+ ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
+ });
+ }
+ catch (Exception ex)
+ {
+ _dispatcher.Post(() =>
+ {
+ CurrentValue = null;
+ CurrentStatus = $"Error: {ex.Message}";
+ SourceTimestamp = null;
+ ServerTimestamp = null;
+ });
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanReadOrWrite))]
+ private async Task WriteAsync()
+ {
+ if (string.IsNullOrEmpty(SelectedNodeId) || WriteValue == null) return;
+
+ try
+ {
+ var nodeId = NodeId.Parse(SelectedNodeId);
+ var statusCode = await _service.WriteValueAsync(nodeId, WriteValue);
+
+ _dispatcher.Post(() =>
+ {
+ WriteStatus = statusCode.ToString();
+ });
+ }
+ catch (Exception ex)
+ {
+ _dispatcher.Post(() =>
+ {
+ WriteStatus = $"Error: {ex.Message}";
+ });
+ }
+ }
+
+ ///
+ /// Clears all displayed values.
+ ///
+ public void Clear()
+ {
+ SelectedNodeId = null;
+ CurrentValue = null;
+ CurrentStatus = null;
+ SourceTimestamp = null;
+ ServerTimestamp = null;
+ WriteValue = null;
+ WriteStatus = null;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs
new file mode 100644
index 0000000..1783c66
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs
@@ -0,0 +1,30 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// Represents a single active subscription row.
+///
+public partial class SubscriptionItemViewModel : ObservableObject
+{
+ /// The monitored NodeId.
+ public string NodeId { get; }
+
+ /// The subscription interval in milliseconds.
+ public int IntervalMs { get; }
+
+ [ObservableProperty]
+ private string? _value;
+
+ [ObservableProperty]
+ private string? _status;
+
+ [ObservableProperty]
+ private string? _timestamp;
+
+ public SubscriptionItemViewModel(string nodeId, int intervalMs)
+ {
+ NodeId = nodeId;
+ IntervalMs = intervalMs;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
new file mode 100644
index 0000000..a7c1ab5
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
@@ -0,0 +1,133 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// ViewModel for the subscriptions panel.
+///
+public partial class SubscriptionsViewModel : ObservableObject
+{
+ private readonly IOpcUaClientService _service;
+ private readonly IUiDispatcher _dispatcher;
+
+ /// Currently active subscriptions.
+ public ObservableCollection ActiveSubscriptions { get; } = new();
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
+ private string? _newNodeIdText;
+
+ [ObservableProperty]
+ private int _newInterval = 1000;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
+ private bool _isConnected;
+
+ [ObservableProperty]
+ private int _subscriptionCount;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
+ private SubscriptionItemViewModel? _selectedSubscription;
+
+ public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
+ {
+ _service = service;
+ _dispatcher = dispatcher;
+ _service.DataChanged += OnDataChanged;
+ }
+
+ private void OnDataChanged(object? sender, DataChangedEventArgs e)
+ {
+ _dispatcher.Post(() =>
+ {
+ foreach (var item in ActiveSubscriptions)
+ {
+ if (item.NodeId == e.NodeId)
+ {
+ item.Value = e.Value.Value?.ToString() ?? "(null)";
+ item.Status = e.Value.StatusCode.ToString();
+ item.Timestamp = e.Value.SourceTimestamp.ToString("O");
+ }
+ }
+ });
+ }
+
+ private bool CanAddSubscription() => IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
+
+ [RelayCommand(CanExecute = nameof(CanAddSubscription))]
+ private async Task AddSubscriptionAsync()
+ {
+ if (string.IsNullOrWhiteSpace(NewNodeIdText)) return;
+
+ var nodeIdStr = NewNodeIdText;
+ var interval = NewInterval;
+
+ try
+ {
+ var nodeId = NodeId.Parse(nodeIdStr);
+ await _service.SubscribeAsync(nodeId, interval);
+
+ _dispatcher.Post(() =>
+ {
+ ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, interval));
+ SubscriptionCount = ActiveSubscriptions.Count;
+ });
+ }
+ catch
+ {
+ // Subscription failed; no item added
+ }
+ }
+
+ private bool CanRemoveSubscription() => IsConnected && SelectedSubscription != null;
+
+ [RelayCommand(CanExecute = nameof(CanRemoveSubscription))]
+ private async Task RemoveSubscriptionAsync()
+ {
+ if (SelectedSubscription == null) return;
+
+ var item = SelectedSubscription;
+
+ try
+ {
+ var nodeId = NodeId.Parse(item.NodeId);
+ await _service.UnsubscribeAsync(nodeId);
+
+ _dispatcher.Post(() =>
+ {
+ ActiveSubscriptions.Remove(item);
+ SubscriptionCount = ActiveSubscriptions.Count;
+ });
+ }
+ catch
+ {
+ // Unsubscribe failed
+ }
+ }
+
+ ///
+ /// Clears all subscriptions and resets state.
+ ///
+ public void Clear()
+ {
+ ActiveSubscriptions.Clear();
+ SubscriptionCount = 0;
+ }
+
+ ///
+ /// Unhooks event handlers from the service.
+ ///
+ public void Teardown()
+ {
+ _service.DataChanged -= OnDataChanged;
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs
new file mode 100644
index 0000000..dd80e2f
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs
@@ -0,0 +1,122 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+///
+/// Represents a single node in the OPC UA browse tree with lazy-load support.
+///
+public partial class TreeNodeViewModel : ObservableObject
+{
+ private static readonly TreeNodeViewModel PlaceholderSentinel = new();
+
+ private readonly IOpcUaClientService? _service;
+ private readonly IUiDispatcher? _dispatcher;
+ private bool _hasLoadedChildren;
+
+ /// The string NodeId of this node.
+ public string NodeId { get; }
+
+ /// The display name shown in the tree.
+ public string DisplayName { get; }
+
+ /// The OPC UA node class (Object, Variable, etc.).
+ public string NodeClass { get; }
+
+ /// Whether this node has child references.
+ public bool HasChildren { get; }
+
+ /// Child nodes (may contain a placeholder sentinel before first expand).
+ public ObservableCollection Children { get; } = new();
+
+ [ObservableProperty]
+ private bool _isExpanded;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ ///
+ /// Private constructor for the placeholder sentinel only.
+ ///
+ private TreeNodeViewModel()
+ {
+ NodeId = string.Empty;
+ DisplayName = "Loading...";
+ NodeClass = string.Empty;
+ HasChildren = false;
+ }
+
+ public TreeNodeViewModel(
+ string nodeId,
+ string displayName,
+ string nodeClass,
+ bool hasChildren,
+ IOpcUaClientService service,
+ IUiDispatcher dispatcher)
+ {
+ NodeId = nodeId;
+ DisplayName = displayName;
+ NodeClass = nodeClass;
+ HasChildren = hasChildren;
+ _service = service;
+ _dispatcher = dispatcher;
+
+ if (hasChildren)
+ {
+ Children.Add(PlaceholderSentinel);
+ }
+ }
+
+ partial void OnIsExpandedChanged(bool value)
+ {
+ if (value && !_hasLoadedChildren && HasChildren)
+ {
+ _ = LoadChildrenAsync();
+ }
+ }
+
+ private async Task LoadChildrenAsync()
+ {
+ if (_service == null || _dispatcher == null) return;
+
+ _hasLoadedChildren = true;
+ IsLoading = true;
+
+ try
+ {
+ var nodeId = Opc.Ua.NodeId.Parse(NodeId);
+ var results = await _service.BrowseAsync(nodeId);
+
+ _dispatcher.Post(() =>
+ {
+ Children.Clear();
+ foreach (var result in results)
+ {
+ Children.Add(new TreeNodeViewModel(
+ result.NodeId,
+ result.DisplayName,
+ result.NodeClass,
+ result.HasChildren,
+ _service,
+ _dispatcher));
+ }
+ });
+ }
+ catch
+ {
+ _dispatcher.Post(() => Children.Clear());
+ }
+ finally
+ {
+ _dispatcher.Post(() => IsLoading = false);
+ }
+ }
+
+ ///
+ /// Returns whether this node instance is the placeholder sentinel.
+ ///
+ internal bool IsPlaceholder => ReferenceEquals(this, PlaceholderSentinel);
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml
new file mode 100644
index 0000000..74938f3
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs
new file mode 100644
index 0000000..6160e45
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+public partial class AlarmsView : UserControl
+{
+ public AlarmsView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml
new file mode 100644
index 0000000..db80315
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs
new file mode 100644
index 0000000..becc6c4
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+public partial class BrowseTreeView : UserControl
+{
+ public BrowseTreeView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml
new file mode 100644
index 0000000..3298096
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs
new file mode 100644
index 0000000..d0ea5d5
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+public partial class HistoryView : UserControl
+{
+ public HistoryView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml
new file mode 100644
index 0000000..5d32252
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..cec30c8
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs
@@ -0,0 +1,34 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLoaded(RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+
+ // Wire up tree selection to the main ViewModel
+ var browseTreeView = this.FindControl("BrowseTreePanel");
+ var treeView = browseTreeView?.FindControl("BrowseTree");
+ if (treeView != null)
+ {
+ treeView.SelectionChanged += OnTreeSelectionChanged;
+ }
+ }
+
+ private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm && sender is TreeView treeView)
+ {
+ vm.SelectedTreeNode = treeView.SelectedItem as TreeNodeViewModel;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml
new file mode 100644
index 0000000..1fb5761
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs
new file mode 100644
index 0000000..0feaa00
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+public partial class ReadWriteView : UserControl
+{
+ public ReadWriteView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml
new file mode 100644
index 0000000..3c2d1d5
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs
new file mode 100644
index 0000000..3d3692b
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
+
+public partial class SubscriptionsView : UserControl
+{
+ public SubscriptionsView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj
new file mode 100644
index 0000000..2175e15
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj
@@ -0,0 +1,30 @@
+
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ ZB.MOM.WW.LmxOpcUa.Client.UI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
new file mode 100644
index 0000000..72afafa
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
@@ -0,0 +1,168 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class AlarmsCommandTests
+{
+ [Fact]
+ public async Task Execute_SubscribesToAlarms()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new AlarmsCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Interval = 2000
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
+ fakeService.SubscribeAlarmsCalls[0].IntervalMs.ShouldBe(2000);
+ fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Execute_WithNode_PassesSourceNodeId()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new AlarmsCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=AlarmSource"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
+ fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
+ fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
+ }
+
+ [Fact]
+ public async Task Execute_WithRefresh_RequestsConditionRefresh()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new AlarmsCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Refresh = true
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.RequestConditionRefreshCalled.ShouldBeTrue();
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Condition refresh requested.");
+ }
+
+ [Fact]
+ public async Task Execute_RefreshFailure_PrintsError()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ConditionRefreshException = new NotSupportedException("Not supported")
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new AlarmsCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Refresh = true
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Condition refresh not supported:");
+ }
+
+ [Fact]
+ public async Task Execute_UnsubscribesOnCancellation()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new AlarmsCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.UnsubscribeAlarmsCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new AlarmsCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/BrowseCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
new file mode 100644
index 0000000..4f3bbf6
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
@@ -0,0 +1,146 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class BrowseCommandTests
+{
+ [Fact]
+ public async Task Execute_PrintsBrowseResults()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ BrowseResults = new List
+ {
+ new BrowseResult("ns=2;s=Obj1", "Object1", "Object", true),
+ new BrowseResult("ns=2;s=Var1", "Variable1", "Variable", false),
+ new BrowseResult("ns=2;s=Meth1", "Method1", "Method", false)
+ }
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new BrowseCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("[Object] Object1 (NodeId: ns=2;s=Obj1)");
+ output.ShouldContain("[Variable] Variable1 (NodeId: ns=2;s=Var1)");
+ output.ShouldContain("[Method] Method1 (NodeId: ns=2;s=Meth1)");
+ }
+
+ [Fact]
+ public async Task Execute_BrowsesFromSpecifiedNode()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ BrowseResults = new List()
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new BrowseCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=StartNode"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.BrowseNodeIds.Count.ShouldBe(1);
+ fakeService.BrowseNodeIds[0].ShouldNotBeNull();
+ fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
+ }
+
+ [Fact]
+ public async Task Execute_DefaultBrowsesFromNull()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ BrowseResults = new List()
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new BrowseCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.BrowseNodeIds.Count.ShouldBe(1);
+ fakeService.BrowseNodeIds[0].ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Execute_NonRecursive_BrowsesSingleLevel()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ BrowseResults = new List
+ {
+ new BrowseResult("ns=2;s=Child", "Child", "Object", true)
+ }
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new BrowseCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Depth = 5 // Should be ignored without recursive flag
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ // Only the root level browse should happen, not child
+ fakeService.BrowseNodeIds.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Execute_Recursive_BrowsesChildren()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ // Override browse to return children only on first call
+ // We can't easily do this with the simple fake, but the default returns results with HasChildren=true
+ // which will trigger child browse with recursive=true, depth=2
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new BrowseCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Recursive = true,
+ Depth = 2
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ // Root browse + child browse (for Node1 which HasChildren=true)
+ fakeService.BrowseNodeIds.Count.ShouldBeGreaterThan(1);
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ BrowseResults = new List()
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new BrowseCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/CommandBaseTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/CommandBaseTests.cs
new file mode 100644
index 0000000..2068b4a
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/CommandBaseTests.cs
@@ -0,0 +1,90 @@
+using CliFx.Infrastructure;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class CommandBaseTests
+{
+ [Fact]
+ public async Task CommonOptions_MapToConnectionSettings_Correctly()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://myserver:4840",
+ Username = "admin",
+ Password = "secret",
+ Security = "sign",
+ FailoverUrls = "opc.tcp://backup1:4840,opc.tcp://backup2:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var settings = fakeService.LastConnectionSettings;
+ settings.ShouldNotBeNull();
+ settings.EndpointUrl.ShouldBe("opc.tcp://myserver:4840");
+ settings.Username.ShouldBe("admin");
+ settings.Password.ShouldBe("secret");
+ settings.SecurityMode.ShouldBe(SecurityMode.Sign);
+ settings.FailoverUrls.ShouldNotBeNull();
+ settings.FailoverUrls!.Length.ShouldBe(3); // primary + 2 failover
+ settings.FailoverUrls[0].ShouldBe("opc.tcp://myserver:4840");
+ settings.AutoAcceptCertificates.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task SecurityOption_Encrypt_MapsToSignAndEncrypt()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Security = "encrypt"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.LastConnectionSettings!.SecurityMode.ShouldBe(SecurityMode.SignAndEncrypt);
+ }
+
+ [Fact]
+ public async Task SecurityOption_None_MapsToNone()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ Security = "none"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.LastConnectionSettings!.SecurityMode.ShouldBe(SecurityMode.None);
+ }
+
+ [Fact]
+ public async Task NoFailoverUrls_FailoverUrlsIsNull()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.LastConnectionSettings!.FailoverUrls.ShouldBeNull();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ConnectCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ConnectCommandTests.cs
new file mode 100644
index 0000000..b9ad440
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ConnectCommandTests.cs
@@ -0,0 +1,78 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class ConnectCommandTests
+{
+ [Fact]
+ public async Task Execute_PrintsConnectionInfo()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ConnectionInfoResult = new ConnectionInfo(
+ "opc.tcp://testhost:4840",
+ "MyServer",
+ "SignAndEncrypt",
+ "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256",
+ "session-42",
+ "MySession")
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://testhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Connected to: opc.tcp://testhost:4840");
+ output.ShouldContain("Server: MyServer");
+ output.ShouldContain("Security Mode: SignAndEncrypt");
+ output.ShouldContain("Security Policy: http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256");
+ output.ShouldContain("Connection successful.");
+ }
+
+ [Fact]
+ public async Task Execute_CallsConnectAndDisconnect()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.ConnectCalled.ShouldBeTrue();
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsOnError()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ConnectException = new InvalidOperationException("Connection refused")
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ConnectCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ // The command should propagate the exception but still clean up.
+ // Since connect fails, service is null in finally, so no disconnect.
+ await Should.ThrowAsync(
+ async () => await command.ExecuteAsync(console));
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs
new file mode 100644
index 0000000..f525aa6
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs
@@ -0,0 +1,184 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+
+///
+/// Fake implementation of for unit testing commands.
+/// Records all method calls and returns configurable results.
+///
+public sealed class FakeOpcUaClientService : IOpcUaClientService
+{
+ // Track calls
+ public bool ConnectCalled { get; private set; }
+ public ConnectionSettings? LastConnectionSettings { get; private set; }
+ public bool DisconnectCalled { get; private set; }
+ public bool DisposeCalled { get; private set; }
+ public List ReadNodeIds { get; } = new();
+ public List<(NodeId NodeId, object Value)> WriteValues { get; } = new();
+ public List BrowseNodeIds { get; } = new();
+ public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = new();
+ public List UnsubscribeCalls { get; } = new();
+ public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = new();
+ public bool UnsubscribeAlarmsCalled { get; private set; }
+ public bool RequestConditionRefreshCalled { get; private set; }
+ public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = new();
+ public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)> HistoryReadAggregateCalls { get; } = new();
+ public bool GetRedundancyInfoCalled { get; private set; }
+
+ // Configurable results
+ public ConnectionInfo ConnectionInfoResult { get; set; } = new ConnectionInfo(
+ "opc.tcp://localhost:4840",
+ "TestServer",
+ "None",
+ "http://opcfoundation.org/UA/SecurityPolicy#None",
+ "session-1",
+ "TestSession");
+
+ public DataValue ReadValueResult { get; set; } = new DataValue(
+ new Variant(42),
+ StatusCodes.Good,
+ DateTime.UtcNow,
+ DateTime.UtcNow);
+
+ public StatusCode WriteStatusCodeResult { get; set; } = StatusCodes.Good;
+
+ public IReadOnlyList BrowseResults { get; set; } = new List
+ {
+ new BrowseResult("ns=2;s=Node1", "Node1", "Object", true),
+ new BrowseResult("ns=2;s=Node2", "Node2", "Variable", false)
+ };
+
+ public IReadOnlyList HistoryReadResult { get; set; } = new List
+ {
+ new DataValue(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
+ new DataValue(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
+ };
+
+ public RedundancyInfo RedundancyInfoResult { get; set; } = new RedundancyInfo(
+ "Warm", 200, new[] { "urn:server1", "urn:server2" }, "urn:app:test");
+
+ public Exception? ConnectException { get; set; }
+ public Exception? ReadException { get; set; }
+ public Exception? WriteException { get; set; }
+ public Exception? ConditionRefreshException { get; set; }
+
+ // IOpcUaClientService implementation
+ public bool IsConnected => ConnectCalled && !DisconnectCalled;
+ public ConnectionInfo? CurrentConnectionInfo => ConnectCalled ? ConnectionInfoResult : null;
+
+ public event EventHandler? DataChanged;
+ public event EventHandler? AlarmEvent;
+ public event EventHandler? ConnectionStateChanged;
+
+ public Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
+ {
+ ConnectCalled = true;
+ LastConnectionSettings = settings;
+ if (ConnectException != null) throw ConnectException;
+ return Task.FromResult(ConnectionInfoResult);
+ }
+
+ public Task DisconnectAsync(CancellationToken ct = default)
+ {
+ DisconnectCalled = true;
+ return Task.CompletedTask;
+ }
+
+ public Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
+ {
+ ReadNodeIds.Add(nodeId);
+ if (ReadException != null) throw ReadException;
+ return Task.FromResult(ReadValueResult);
+ }
+
+ public Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
+ {
+ WriteValues.Add((nodeId, value));
+ if (WriteException != null) throw WriteException;
+ return Task.FromResult(WriteStatusCodeResult);
+ }
+
+ public Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
+ {
+ BrowseNodeIds.Add(parentNodeId);
+ return Task.FromResult(BrowseResults);
+ }
+
+ public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
+ {
+ SubscribeCalls.Add((nodeId, intervalMs));
+ return Task.CompletedTask;
+ }
+
+ public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
+ {
+ UnsubscribeCalls.Add(nodeId);
+ return Task.CompletedTask;
+ }
+
+ public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
+ {
+ SubscribeAlarmsCalls.Add((sourceNodeId, intervalMs));
+ return Task.CompletedTask;
+ }
+
+ public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
+ {
+ UnsubscribeAlarmsCalled = true;
+ return Task.CompletedTask;
+ }
+
+ public Task RequestConditionRefreshAsync(CancellationToken ct = default)
+ {
+ RequestConditionRefreshCalled = true;
+ if (ConditionRefreshException != null) throw ConditionRefreshException;
+ return Task.CompletedTask;
+ }
+
+ public Task> HistoryReadRawAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
+ {
+ HistoryReadRawCalls.Add((nodeId, startTime, endTime, maxValues));
+ return Task.FromResult(HistoryReadResult);
+ }
+
+ public Task> HistoryReadAggregateAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
+ double intervalMs = 3600000, CancellationToken ct = default)
+ {
+ HistoryReadAggregateCalls.Add((nodeId, startTime, endTime, aggregate, intervalMs));
+ return Task.FromResult(HistoryReadResult);
+ }
+
+ public Task GetRedundancyInfoAsync(CancellationToken ct = default)
+ {
+ GetRedundancyInfoCalled = true;
+ return Task.FromResult(RedundancyInfoResult);
+ }
+
+ /// Raises the DataChanged event for testing subscribe commands.
+ public void RaiseDataChanged(string nodeId, DataValue value)
+ {
+ DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
+ }
+
+ /// Raises the AlarmEvent for testing alarm commands.
+ public void RaiseAlarmEvent(AlarmEventArgs args)
+ {
+ AlarmEvent?.Invoke(this, args);
+ }
+
+ /// Raises the ConnectionStateChanged event for testing.
+ public void RaiseConnectionStateChanged(ConnectionState oldState, ConnectionState newState, string endpointUrl)
+ {
+ ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
+ }
+
+ public void Dispose()
+ {
+ DisposeCalled = true;
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientServiceFactory.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientServiceFactory.cs
new file mode 100644
index 0000000..8f28152
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientServiceFactory.cs
@@ -0,0 +1,18 @@
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+
+///
+/// Fake factory that returns a pre-configured for testing.
+///
+public sealed class FakeOpcUaClientServiceFactory : IOpcUaClientServiceFactory
+{
+ private readonly FakeOpcUaClientService _service;
+
+ public FakeOpcUaClientServiceFactory(FakeOpcUaClientService service)
+ {
+ _service = service;
+ }
+
+ public IOpcUaClientService Create() => _service;
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs
new file mode 100644
index 0000000..f654ddb
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs
@@ -0,0 +1,141 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class HistoryReadCommandTests
+{
+ [Fact]
+ public async Task Execute_RawRead_PrintsValues()
+ {
+ var time1 = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc);
+ var time2 = new DateTime(2025, 6, 15, 11, 0, 0, DateTimeKind.Utc);
+ var fakeService = new FakeOpcUaClientService
+ {
+ HistoryReadResult = new List
+ {
+ new DataValue(new Variant(10.5), StatusCodes.Good, time1, time1),
+ new DataValue(new Variant(20.3), StatusCodes.Good, time2, time2)
+ }
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new HistoryReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ StartTime = "2025-06-15T00:00:00Z",
+ EndTime = "2025-06-15T23:59:59Z"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("History for ns=2;s=HistNode");
+ output.ShouldContain("Timestamp");
+ output.ShouldContain("Value");
+ output.ShouldContain("Status");
+ output.ShouldContain("2 values returned.");
+ }
+
+ [Fact]
+ public async Task Execute_RawRead_CallsHistoryReadRaw()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new HistoryReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ MaxValues = 500
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.HistoryReadRawCalls.Count.ShouldBe(1);
+ fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
+ fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
+ }
+
+ [Fact]
+ public async Task Execute_AggregateRead_CallsHistoryReadAggregate()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new HistoryReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ Aggregate = "Average",
+ IntervalMs = 60000
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.HistoryReadAggregateCalls.Count.ShouldBe(1);
+ fakeService.HistoryReadAggregateCalls[0].Aggregate.ShouldBe(AggregateType.Average);
+ fakeService.HistoryReadAggregateCalls[0].IntervalMs.ShouldBe(60000);
+ }
+
+ [Fact]
+ public async Task Execute_AggregateRead_PrintsAggregateInfo()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new HistoryReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ Aggregate = "Maximum",
+ IntervalMs = 7200000
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Maximum");
+ output.ShouldContain("7200000");
+ }
+
+ [Fact]
+ public async Task Execute_InvalidAggregate_ThrowsArgumentException()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new HistoryReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ Aggregate = "InvalidAgg"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await Should.ThrowAsync(
+ async () => await command.ExecuteAsync(console));
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new HistoryReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/NodeIdParserTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/NodeIdParserTests.cs
new file mode 100644
index 0000000..d8dbbe9
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/NodeIdParserTests.cs
@@ -0,0 +1,87 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class NodeIdParserTests
+{
+ [Fact]
+ public void Parse_NullInput_ReturnsNull()
+ {
+ NodeIdParser.Parse(null).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Parse_EmptyString_ReturnsNull()
+ {
+ NodeIdParser.Parse("").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Parse_WhitespaceOnly_ReturnsNull()
+ {
+ NodeIdParser.Parse(" ").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Parse_StandardStringFormat_ReturnsNodeId()
+ {
+ var result = NodeIdParser.Parse("ns=2;s=MyNode");
+ result.ShouldNotBeNull();
+ result.NamespaceIndex.ShouldBe((ushort)2);
+ result.Identifier.ShouldBe("MyNode");
+ }
+
+ [Fact]
+ public void Parse_NumericFormat_ReturnsNodeId()
+ {
+ var result = NodeIdParser.Parse("i=85");
+ result.ShouldNotBeNull();
+ result.IdType.ShouldBe(IdType.Numeric);
+ }
+
+ [Fact]
+ public void Parse_BareNumeric_ReturnsNamespace0NumericNodeId()
+ {
+ var result = NodeIdParser.Parse("85");
+ result.ShouldNotBeNull();
+ result.NamespaceIndex.ShouldBe((ushort)0);
+ result.Identifier.ShouldBe((uint)85);
+ }
+
+ [Fact]
+ public void Parse_WithWhitespacePadding_Trims()
+ {
+ var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
+ result.ShouldNotBeNull();
+ result.Identifier.ShouldBe("MyNode");
+ }
+
+ [Fact]
+ public void Parse_InvalidFormat_ThrowsFormatException()
+ {
+ Should.Throw(() => NodeIdParser.Parse("not-a-node-id"));
+ }
+
+ [Fact]
+ public void ParseRequired_NullInput_ThrowsArgumentException()
+ {
+ Should.Throw(() => NodeIdParser.ParseRequired(null));
+ }
+
+ [Fact]
+ public void ParseRequired_EmptyInput_ThrowsArgumentException()
+ {
+ Should.Throw(() => NodeIdParser.ParseRequired(""));
+ }
+
+ [Fact]
+ public void ParseRequired_ValidInput_ReturnsNodeId()
+ {
+ var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
+ result.ShouldNotBeNull();
+ result.Identifier.ShouldBe("TestNode");
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/PlaceholderTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/PlaceholderTests.cs
new file mode 100644
index 0000000..3e06258
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/PlaceholderTests.cs
@@ -0,0 +1 @@
+// This file intentionally left empty. Real tests are in separate files.
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ReadCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ReadCommandTests.cs
new file mode 100644
index 0000000..06e2038
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ReadCommandTests.cs
@@ -0,0 +1,99 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class ReadCommandTests
+{
+ [Fact]
+ public async Task Execute_PrintsReadValue()
+ {
+ var sourceTime = new DateTime(2025, 6, 15, 10, 30, 0, DateTimeKind.Utc);
+ var serverTime = new DateTime(2025, 6, 15, 10, 30, 1, DateTimeKind.Utc);
+ var fakeService = new FakeOpcUaClientService
+ {
+ ReadValueResult = new DataValue(
+ new Variant("Hello"),
+ StatusCodes.Good,
+ sourceTime,
+ serverTime)
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestNode"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Node: ns=2;s=TestNode");
+ output.ShouldContain("Value: Hello");
+ output.ShouldContain("Status:");
+ output.ShouldContain("Source Time:");
+ output.ShouldContain("Server Time:");
+ }
+
+ [Fact]
+ public async Task Execute_CallsReadValueWithCorrectNodeId()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=MyVariable"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.ReadNodeIds.Count.ShouldBe(1);
+ fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestNode"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsEvenOnReadError()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ReadException = new InvalidOperationException("Read failed")
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new ReadCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestNode"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await Should.ThrowAsync(
+ async () => await command.ExecuteAsync(console));
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/RedundancyCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/RedundancyCommandTests.cs
new file mode 100644
index 0000000..f80fe90
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/RedundancyCommandTests.cs
@@ -0,0 +1,93 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class RedundancyCommandTests
+{
+ [Fact]
+ public async Task Execute_PrintsRedundancyInfo()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ RedundancyInfoResult = new RedundancyInfo(
+ "Hot", 250, new[] { "urn:server:primary", "urn:server:secondary" }, "urn:app:myserver")
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new RedundancyCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Redundancy Mode: Hot");
+ output.ShouldContain("Service Level: 250");
+ output.ShouldContain("Server URIs:");
+ output.ShouldContain(" - urn:server:primary");
+ output.ShouldContain(" - urn:server:secondary");
+ output.ShouldContain("Application URI: urn:app:myserver");
+ }
+
+ [Fact]
+ public async Task Execute_NoServerUris_OmitsUriSection()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ RedundancyInfoResult = new RedundancyInfo(
+ "None", 100, Array.Empty(), "urn:app:standalone")
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new RedundancyCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Redundancy Mode: None");
+ output.ShouldContain("Service Level: 100");
+ output.ShouldNotContain("Server URIs:");
+ output.ShouldContain("Application URI: urn:app:standalone");
+ }
+
+ [Fact]
+ public async Task Execute_CallsGetRedundancyInfo()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new RedundancyCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.GetRedundancyInfoCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new RedundancyCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs
new file mode 100644
index 0000000..04d9e21
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs
@@ -0,0 +1,120 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class SubscribeCommandTests
+{
+ [Fact]
+ public async Task Execute_SubscribesWithCorrectParameters()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new SubscribeCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestVar",
+ Interval = 500
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ // The subscribe command waits for cancellation. We need to cancel it.
+ // Use the console's cancellation to trigger stop.
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ // Give it a moment to subscribe, then cancel
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.SubscribeCalls.Count.ShouldBe(1);
+ fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
+ fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
+ }
+
+ [Fact]
+ public async Task Execute_UnsubscribesOnCancellation()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new SubscribeCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestVar"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.UnsubscribeCalls.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new SubscribeCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestVar"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Execute_PrintsSubscriptionMessage()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new SubscribeCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=TestVar",
+ Interval = 2000
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+
+ var task = Task.Run(async () =>
+ {
+ await command.ExecuteAsync(console);
+ });
+
+ await Task.Delay(100);
+ console.RequestCancellation();
+ await task;
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)");
+ output.ShouldContain("Unsubscribed.");
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/TestConsoleHelper.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/TestConsoleHelper.cs
new file mode 100644
index 0000000..4595bcd
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/TestConsoleHelper.cs
@@ -0,0 +1,35 @@
+using CliFx.Infrastructure;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+///
+/// Helper for creating CliFx instances and reading their output.
+///
+public static class TestConsoleHelper
+{
+ ///
+ /// Creates a new for testing.
+ ///
+ public static FakeInMemoryConsole CreateConsole()
+ {
+ return new FakeInMemoryConsole();
+ }
+
+ ///
+ /// Reads all text written to the console's standard output.
+ ///
+ public static string GetOutput(FakeInMemoryConsole console)
+ {
+ console.Output.Flush();
+ return console.ReadOutputString();
+ }
+
+ ///
+ /// Reads all text written to the console's standard error.
+ ///
+ public static string GetError(FakeInMemoryConsole console)
+ {
+ console.Error.Flush();
+ return console.ReadErrorString();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/WriteCommandTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/WriteCommandTests.cs
new file mode 100644
index 0000000..10acf6c
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/WriteCommandTests.cs
@@ -0,0 +1,103 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
+
+public class WriteCommandTests
+{
+ [Fact]
+ public async Task Execute_WritesSuccessfully()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ReadValueResult = new DataValue(new Variant(42)),
+ WriteStatusCodeResult = StatusCodes.Good
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new WriteCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=MyVar",
+ Value = "100"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Write successful: ns=2;s=MyVar = 100");
+ }
+
+ [Fact]
+ public async Task Execute_ReportsFailure()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ReadValueResult = new DataValue(new Variant("current")),
+ WriteStatusCodeResult = StatusCodes.BadNotWritable
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new WriteCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=ReadOnly",
+ Value = "newvalue"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Write failed:");
+ }
+
+ [Fact]
+ public async Task Execute_ReadsCurrentValueThenWrites()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ReadValueResult = new DataValue(new Variant(3.14)),
+ WriteStatusCodeResult = StatusCodes.Good
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new WriteCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=FloatVar",
+ Value = "2.718"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ // Should read first to get current type, then write
+ fakeService.ReadNodeIds.Count.ShouldBe(1);
+ fakeService.WriteValues.Count.ShouldBe(1);
+ fakeService.WriteValues[0].Value.ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task Execute_DisconnectsInFinally()
+ {
+ var fakeService = new FakeOpcUaClientService
+ {
+ ReadValueResult = new DataValue(new Variant("test"))
+ };
+ var factory = new FakeOpcUaClientServiceFactory(fakeService);
+ var command = new WriteCommand(factory)
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=Node",
+ Value = "value"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.DisconnectCalled.ShouldBeTrue();
+ fakeService.DisposeCalled.ShouldBeTrue();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.csproj b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.csproj
new file mode 100644
index 0000000..9de99fb
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeApplicationConfigurationFactory.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeApplicationConfigurationFactory.cs
new file mode 100644
index 0000000..2cf1c79
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeApplicationConfigurationFactory.cs
@@ -0,0 +1,38 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
+
+internal sealed class FakeApplicationConfigurationFactory : IApplicationConfigurationFactory
+{
+ public bool ThrowOnCreate { get; set; }
+ public int CreateCallCount { get; private set; }
+ public ConnectionSettings? LastSettings { get; private set; }
+
+ public Task CreateAsync(ConnectionSettings settings, CancellationToken ct)
+ {
+ CreateCallCount++;
+ LastSettings = settings;
+
+ if (ThrowOnCreate)
+ throw new InvalidOperationException("FakeApplicationConfigurationFactory configured to fail.");
+
+ var config = new ApplicationConfiguration
+ {
+ ApplicationName = "FakeClient",
+ ApplicationUri = "urn:localhost:FakeClient",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ AutoAcceptUntrustedCertificates = true
+ },
+ ClientConfiguration = new ClientConfiguration
+ {
+ DefaultSessionTimeout = settings.SessionTimeoutSeconds * 1000
+ }
+ };
+
+ return Task.FromResult(config);
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs
new file mode 100644
index 0000000..9227798
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs
@@ -0,0 +1,34 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
+
+internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
+{
+ public bool ThrowOnSelect { get; set; }
+ public int SelectCallCount { get; private set; }
+ public string? LastEndpointUrl { get; private set; }
+
+ public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode)
+ {
+ SelectCallCount++;
+ LastEndpointUrl = endpointUrl;
+
+ if (ThrowOnSelect)
+ throw new InvalidOperationException($"No endpoint found for {endpointUrl}");
+
+ return new EndpointDescription
+ {
+ EndpointUrl = endpointUrl,
+ SecurityMode = requestedMode,
+ SecurityPolicyUri = requestedMode == MessageSecurityMode.None
+ ? SecurityPolicies.None
+ : SecurityPolicies.Basic256Sha256,
+ Server = new ApplicationDescription
+ {
+ ApplicationName = "FakeServer",
+ ApplicationUri = "urn:localhost:FakeServer"
+ }
+ };
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
new file mode 100644
index 0000000..5b098d6
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
@@ -0,0 +1,150 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
+
+internal sealed class FakeSessionAdapter : ISessionAdapter
+{
+ private Action? _keepAliveCallback;
+ private readonly List _createdSubscriptions = new();
+
+ public bool Connected { get; set; } = true;
+ public string SessionId { get; set; } = "ns=0;i=12345";
+ public string SessionName { get; set; } = "FakeSession";
+ public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
+ public string ServerName { get; set; } = "FakeServer";
+ public string SecurityMode { get; set; } = "None";
+ public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#None";
+ public NamespaceTable NamespaceUris { get; set; } = new();
+
+ public bool Closed { get; private set; }
+ public bool Disposed { get; private set; }
+ public int ReadCount { get; private set; }
+ public int WriteCount { get; private set; }
+ public int BrowseCount { get; private set; }
+ public int BrowseNextCount { get; private set; }
+ public int HasChildrenCount { get; private set; }
+ public int HistoryReadRawCount { get; private set; }
+ public int HistoryReadAggregateCount { get; private set; }
+
+ // Configurable responses
+ public DataValue? ReadResponse { get; set; }
+ public Func? ReadResponseFunc { get; set; }
+ public StatusCode WriteResponse { get; set; } = StatusCodes.Good;
+ public bool ThrowOnRead { get; set; }
+ public bool ThrowOnWrite { get; set; }
+ public bool ThrowOnBrowse { get; set; }
+
+ public ReferenceDescriptionCollection BrowseResponse { get; set; } = new();
+ public byte[]? BrowseContinuationPoint { get; set; }
+ public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = new();
+ public byte[]? BrowseNextContinuationPoint { get; set; }
+ public bool HasChildrenResponse { get; set; } = false;
+
+ public List HistoryReadRawResponse { get; set; } = new();
+ public List HistoryReadAggregateResponse { get; set; } = new();
+ public bool ThrowOnHistoryReadRaw { get; set; }
+ public bool ThrowOnHistoryReadAggregate { get; set; }
+
+ ///
+ /// The next FakeSubscriptionAdapter to return from CreateSubscriptionAsync.
+ /// If null, a new one is created automatically.
+ ///
+ public FakeSubscriptionAdapter? NextSubscription { get; set; }
+
+ public IReadOnlyList CreatedSubscriptions => _createdSubscriptions;
+
+ public void RegisterKeepAliveHandler(Action callback)
+ {
+ _keepAliveCallback = callback;
+ }
+
+ ///
+ /// Simulates a keep-alive event.
+ ///
+ public void SimulateKeepAlive(bool isGood)
+ {
+ _keepAliveCallback?.Invoke(isGood);
+ }
+
+ public Task ReadValueAsync(NodeId nodeId, CancellationToken ct)
+ {
+ ReadCount++;
+ if (ThrowOnRead)
+ throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Node not found");
+
+ if (ReadResponseFunc != null)
+ return Task.FromResult(ReadResponseFunc(nodeId));
+
+ return Task.FromResult(ReadResponse ?? new DataValue(new Variant(0), StatusCodes.Good));
+ }
+
+ public Task WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct)
+ {
+ WriteCount++;
+ if (ThrowOnWrite)
+ throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Node not found");
+ return Task.FromResult(WriteResponse);
+ }
+
+ public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
+ NodeId nodeId, uint nodeClassMask, CancellationToken ct)
+ {
+ BrowseCount++;
+ if (ThrowOnBrowse)
+ throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Node not found");
+ return Task.FromResult((BrowseContinuationPoint, BrowseResponse));
+ }
+
+ public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
+ byte[] continuationPoint, CancellationToken ct)
+ {
+ BrowseNextCount++;
+ return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
+ }
+
+ public Task HasChildrenAsync(NodeId nodeId, CancellationToken ct)
+ {
+ HasChildrenCount++;
+ return Task.FromResult(HasChildrenResponse);
+ }
+
+ public Task> HistoryReadRawAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
+ {
+ HistoryReadRawCount++;
+ if (ThrowOnHistoryReadRaw)
+ throw new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported, "History not supported");
+ return Task.FromResult>(HistoryReadRawResponse);
+ }
+
+ public Task> HistoryReadAggregateAsync(
+ NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, CancellationToken ct)
+ {
+ HistoryReadAggregateCount++;
+ if (ThrowOnHistoryReadAggregate)
+ throw new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported, "History not supported");
+ return Task.FromResult>(HistoryReadAggregateResponse);
+ }
+
+ public Task CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
+ {
+ var sub = NextSubscription ?? new FakeSubscriptionAdapter();
+ NextSubscription = null;
+ _createdSubscriptions.Add(sub);
+ return Task.FromResult(sub);
+ }
+
+ public Task CloseAsync(CancellationToken ct)
+ {
+ Closed = true;
+ Connected = false;
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ Disposed = true;
+ Connected = false;
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs
new file mode 100644
index 0000000..17fc7c7
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs
@@ -0,0 +1,56 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
+
+internal sealed class FakeSessionFactory : ISessionFactory
+{
+ private readonly Queue _sessions = new();
+ private readonly List _createdSessions = new();
+
+ public int CreateCallCount { get; private set; }
+ public bool ThrowOnCreate { get; set; }
+ public string? LastEndpointUrl { get; private set; }
+
+ ///
+ /// Enqueues a session adapter to be returned on the next call to CreateSessionAsync.
+ ///
+ public void EnqueueSession(FakeSessionAdapter session)
+ {
+ _sessions.Enqueue(session);
+ }
+
+ public IReadOnlyList CreatedSessions => _createdSessions;
+
+ public Task CreateSessionAsync(
+ ApplicationConfiguration config, EndpointDescription endpoint, string sessionName,
+ uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
+ {
+ CreateCallCount++;
+ LastEndpointUrl = endpoint.EndpointUrl;
+
+ if (ThrowOnCreate)
+ throw new InvalidOperationException("FakeSessionFactory configured to fail.");
+
+ FakeSessionAdapter session;
+ if (_sessions.Count > 0)
+ {
+ session = _sessions.Dequeue();
+ }
+ else
+ {
+ session = new FakeSessionAdapter
+ {
+ EndpointUrl = endpoint.EndpointUrl,
+ ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer",
+ SecurityMode = endpoint.SecurityMode.ToString(),
+ SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
+ };
+ }
+
+ // Ensure endpoint URL matches
+ session.EndpointUrl = endpoint.EndpointUrl;
+ _createdSessions.Add(session);
+ return Task.FromResult(session);
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs
new file mode 100644
index 0000000..9df0468
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs
@@ -0,0 +1,88 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
+
+internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
+{
+ private uint _nextHandle = 100;
+ private readonly Dictionary? DataCallback, Action? EventCallback)> _items = new();
+
+ public uint SubscriptionId { get; set; } = 42;
+ public bool Deleted { get; private set; }
+ public bool ConditionRefreshCalled { get; private set; }
+ public bool ThrowOnConditionRefresh { get; set; }
+ public int AddDataChangeCount { get; private set; }
+ public int AddEventCount { get; private set; }
+ public int RemoveCount { get; private set; }
+
+ public Task AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, Action onDataChange, CancellationToken ct)
+ {
+ AddDataChangeCount++;
+ var handle = _nextHandle++;
+ _items[handle] = (nodeId, onDataChange, null);
+ return Task.FromResult(handle);
+ }
+
+ public Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
+ {
+ RemoveCount++;
+ _items.Remove(clientHandle);
+ return Task.CompletedTask;
+ }
+
+ public Task AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter, Action onEvent, CancellationToken ct)
+ {
+ AddEventCount++;
+ var handle = _nextHandle++;
+ _items[handle] = (nodeId, null, onEvent);
+ return Task.FromResult(handle);
+ }
+
+ public Task ConditionRefreshAsync(CancellationToken ct)
+ {
+ ConditionRefreshCalled = true;
+ if (ThrowOnConditionRefresh)
+ throw new InvalidOperationException("Condition refresh not supported");
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(CancellationToken ct)
+ {
+ Deleted = true;
+ _items.Clear();
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ _items.Clear();
+ }
+
+ ///
+ /// Simulates a data change notification for testing.
+ ///
+ public void SimulateDataChange(uint handle, DataValue value)
+ {
+ if (_items.TryGetValue(handle, out var item) && item.DataCallback != null)
+ {
+ item.DataCallback(item.NodeId.ToString(), value);
+ }
+ }
+
+ ///
+ /// Simulates an event notification for testing.
+ ///
+ public void SimulateEvent(uint handle, EventFieldList eventFields)
+ {
+ if (_items.TryGetValue(handle, out var item) && item.EventCallback != null)
+ {
+ item.EventCallback(eventFields);
+ }
+ }
+
+ ///
+ /// Gets the handles of all active items.
+ ///
+ public IReadOnlyCollection ActiveHandles => _items.Keys.ToList();
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
new file mode 100644
index 0000000..1a60463
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
@@ -0,0 +1,67 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
+
+public class AggregateTypeMapperTests
+{
+ [Theory]
+ [InlineData(AggregateType.Average)]
+ [InlineData(AggregateType.Minimum)]
+ [InlineData(AggregateType.Maximum)]
+ [InlineData(AggregateType.Count)]
+ [InlineData(AggregateType.Start)]
+ [InlineData(AggregateType.End)]
+ public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
+ {
+ var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
+ nodeId.ShouldNotBeNull();
+ nodeId.IsNullNodeId.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ToNodeId_Average_MapsCorrectly()
+ {
+ AggregateTypeMapper.ToNodeId(AggregateType.Average).ShouldBe(ObjectIds.AggregateFunction_Average);
+ }
+
+ [Fact]
+ public void ToNodeId_Minimum_MapsCorrectly()
+ {
+ AggregateTypeMapper.ToNodeId(AggregateType.Minimum).ShouldBe(ObjectIds.AggregateFunction_Minimum);
+ }
+
+ [Fact]
+ public void ToNodeId_Maximum_MapsCorrectly()
+ {
+ AggregateTypeMapper.ToNodeId(AggregateType.Maximum).ShouldBe(ObjectIds.AggregateFunction_Maximum);
+ }
+
+ [Fact]
+ public void ToNodeId_Count_MapsCorrectly()
+ {
+ AggregateTypeMapper.ToNodeId(AggregateType.Count).ShouldBe(ObjectIds.AggregateFunction_Count);
+ }
+
+ [Fact]
+ public void ToNodeId_Start_MapsCorrectly()
+ {
+ AggregateTypeMapper.ToNodeId(AggregateType.Start).ShouldBe(ObjectIds.AggregateFunction_Start);
+ }
+
+ [Fact]
+ public void ToNodeId_End_MapsCorrectly()
+ {
+ AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
+ }
+
+ [Fact]
+ public void ToNodeId_InvalidValue_Throws()
+ {
+ Should.Throw(() =>
+ AggregateTypeMapper.ToNodeId((AggregateType)99));
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/FailoverUrlParserTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/FailoverUrlParserTests.cs
new file mode 100644
index 0000000..011943b
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/FailoverUrlParserTests.cs
@@ -0,0 +1,110 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
+
+public class FailoverUrlParserTests
+{
+ [Fact]
+ public void Parse_CsvNull_ReturnsPrimaryOnly()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", (string?)null);
+ result.ShouldBe(new[] { "opc.tcp://primary:4840" });
+ }
+
+ [Fact]
+ public void Parse_CsvEmpty_ReturnsPrimaryOnly()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "");
+ result.ShouldBe(new[] { "opc.tcp://primary:4840" });
+ }
+
+ [Fact]
+ public void Parse_CsvWhitespace_ReturnsPrimaryOnly()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", " ");
+ result.ShouldBe(new[] { "opc.tcp://primary:4840" });
+ }
+
+ [Fact]
+ public void Parse_SingleFailover_ReturnsBoth()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "opc.tcp://backup:4840");
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ }
+
+ [Fact]
+ public void Parse_MultipleFailovers_ReturnsAll()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "opc.tcp://backup1:4840,opc.tcp://backup2:4840");
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup1:4840", "opc.tcp://backup2:4840" });
+ }
+
+ [Fact]
+ public void Parse_TrimsWhitespace()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", " opc.tcp://backup:4840 ");
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ }
+
+ [Fact]
+ public void Parse_DeduplicatesPrimaryInFailoverList()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "opc.tcp://primary:4840,opc.tcp://backup:4840");
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ }
+
+ [Fact]
+ public void Parse_DeduplicatesCaseInsensitive()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://Primary:4840", "opc.tcp://primary:4840");
+ result.ShouldBe(new[] { "opc.tcp://Primary:4840" });
+ }
+
+ [Fact]
+ public void Parse_ArrayNull_ReturnsPrimaryOnly()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", (string[]?)null);
+ result.ShouldBe(new[] { "opc.tcp://primary:4840" });
+ }
+
+ [Fact]
+ public void Parse_ArrayEmpty_ReturnsPrimaryOnly()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", Array.Empty());
+ result.ShouldBe(new[] { "opc.tcp://primary:4840" });
+ }
+
+ [Fact]
+ public void Parse_ArrayWithUrls_ReturnsAll()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
+ new[] { "opc.tcp://backup1:4840", "opc.tcp://backup2:4840" });
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup1:4840", "opc.tcp://backup2:4840" });
+ }
+
+ [Fact]
+ public void Parse_ArrayDeduplicates()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
+ new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ }
+
+ [Fact]
+ public void Parse_ArrayTrimsWhitespace()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
+ new[] { " opc.tcp://backup:4840 " });
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ }
+
+ [Fact]
+ public void Parse_ArraySkipsNullAndEmpty()
+ {
+ var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
+ new[] { null!, "", "opc.tcp://backup:4840" });
+ result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/SecurityModeMapperTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/SecurityModeMapperTests.cs
new file mode 100644
index 0000000..8294113
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/SecurityModeMapperTests.cs
@@ -0,0 +1,58 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
+
+public class SecurityModeMapperTests
+{
+ [Theory]
+ [InlineData(SecurityMode.None, MessageSecurityMode.None)]
+ [InlineData(SecurityMode.Sign, MessageSecurityMode.Sign)]
+ [InlineData(SecurityMode.SignAndEncrypt, MessageSecurityMode.SignAndEncrypt)]
+ public void ToMessageSecurityMode_MapsCorrectly(SecurityMode input, MessageSecurityMode expected)
+ {
+ SecurityModeMapper.ToMessageSecurityMode(input).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void ToMessageSecurityMode_InvalidValue_Throws()
+ {
+ Should.Throw(() =>
+ SecurityModeMapper.ToMessageSecurityMode((SecurityMode)99));
+ }
+
+ [Theory]
+ [InlineData("none", SecurityMode.None)]
+ [InlineData("None", SecurityMode.None)]
+ [InlineData("NONE", SecurityMode.None)]
+ [InlineData("sign", SecurityMode.Sign)]
+ [InlineData("Sign", SecurityMode.Sign)]
+ [InlineData("encrypt", SecurityMode.SignAndEncrypt)]
+ [InlineData("signandencrypt", SecurityMode.SignAndEncrypt)]
+ [InlineData("SignAndEncrypt", SecurityMode.SignAndEncrypt)]
+ public void FromString_ParsesCorrectly(string input, SecurityMode expected)
+ {
+ SecurityModeMapper.FromString(input).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void FromString_WithWhitespace_ParsesCorrectly()
+ {
+ SecurityModeMapper.FromString(" sign ").ShouldBe(SecurityMode.Sign);
+ }
+
+ [Fact]
+ public void FromString_UnknownValue_Throws()
+ {
+ Should.Throw(() => SecurityModeMapper.FromString("invalid"));
+ }
+
+ [Fact]
+ public void FromString_Null_DefaultsToNone()
+ {
+ SecurityModeMapper.FromString(null!).ShouldBe(SecurityMode.None);
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/ValueConverterTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/ValueConverterTests.cs
new file mode 100644
index 0000000..e70a718
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Helpers/ValueConverterTests.cs
@@ -0,0 +1,110 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
+
+public class ValueConverterTests
+{
+ [Fact]
+ public void ConvertValue_Bool_True()
+ {
+ ValueConverter.ConvertValue("True", true).ShouldBe(true);
+ }
+
+ [Fact]
+ public void ConvertValue_Bool_False()
+ {
+ ValueConverter.ConvertValue("False", false).ShouldBe(false);
+ }
+
+ [Fact]
+ public void ConvertValue_Byte()
+ {
+ ValueConverter.ConvertValue("255", (byte)0).ShouldBe((byte)255);
+ }
+
+ [Fact]
+ public void ConvertValue_Short()
+ {
+ ValueConverter.ConvertValue("-100", (short)0).ShouldBe((short)-100);
+ }
+
+ [Fact]
+ public void ConvertValue_UShort()
+ {
+ ValueConverter.ConvertValue("65535", (ushort)0).ShouldBe((ushort)65535);
+ }
+
+ [Fact]
+ public void ConvertValue_Int()
+ {
+ ValueConverter.ConvertValue("42", 0).ShouldBe(42);
+ }
+
+ [Fact]
+ public void ConvertValue_UInt()
+ {
+ ValueConverter.ConvertValue("42", 0u).ShouldBe(42u);
+ }
+
+ [Fact]
+ public void ConvertValue_Long()
+ {
+ ValueConverter.ConvertValue("9999999999", 0L).ShouldBe(9999999999L);
+ }
+
+ [Fact]
+ public void ConvertValue_ULong()
+ {
+ ValueConverter.ConvertValue("18446744073709551615", 0UL).ShouldBe(ulong.MaxValue);
+ }
+
+ [Fact]
+ public void ConvertValue_Float()
+ {
+ ValueConverter.ConvertValue("3.14", 0f).ShouldBe(3.14f);
+ }
+
+ [Fact]
+ public void ConvertValue_Double()
+ {
+ ValueConverter.ConvertValue("3.14159", 0.0).ShouldBe(3.14159);
+ }
+
+ [Fact]
+ public void ConvertValue_String_WhenCurrentIsString()
+ {
+ ValueConverter.ConvertValue("hello", "").ShouldBe("hello");
+ }
+
+ [Fact]
+ public void ConvertValue_String_WhenCurrentIsNull()
+ {
+ ValueConverter.ConvertValue("hello", null).ShouldBe("hello");
+ }
+
+ [Fact]
+ public void ConvertValue_String_WhenCurrentIsUnknownType()
+ {
+ ValueConverter.ConvertValue("hello", new object()).ShouldBe("hello");
+ }
+
+ [Fact]
+ public void ConvertValue_InvalidBool_Throws()
+ {
+ Should.Throw(() => ValueConverter.ConvertValue("notabool", true));
+ }
+
+ [Fact]
+ public void ConvertValue_InvalidInt_Throws()
+ {
+ Should.Throw(() => ValueConverter.ConvertValue("notanint", 0));
+ }
+
+ [Fact]
+ public void ConvertValue_Overflow_Throws()
+ {
+ Should.Throw(() => ValueConverter.ConvertValue("256", (byte)0));
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs
new file mode 100644
index 0000000..9f1869c
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs
@@ -0,0 +1,95 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Models;
+
+public class ConnectionSettingsTests
+{
+ [Fact]
+ public void Defaults_AreCorrect()
+ {
+ var settings = new ConnectionSettings();
+
+ settings.EndpointUrl.ShouldBe(string.Empty);
+ settings.FailoverUrls.ShouldBeNull();
+ settings.Username.ShouldBeNull();
+ settings.Password.ShouldBeNull();
+ settings.SecurityMode.ShouldBe(SecurityMode.None);
+ settings.SessionTimeoutSeconds.ShouldBe(60);
+ settings.AutoAcceptCertificates.ShouldBeTrue();
+ settings.CertificateStorePath.ShouldContain("LmxOpcUaClient");
+ settings.CertificateStorePath.ShouldContain("pki");
+ }
+
+ [Fact]
+ public void Validate_ThrowsOnNullEndpointUrl()
+ {
+ var settings = new ConnectionSettings { EndpointUrl = null! };
+ Should.Throw(() => settings.Validate())
+ .ParamName.ShouldBe("EndpointUrl");
+ }
+
+ [Fact]
+ public void Validate_ThrowsOnEmptyEndpointUrl()
+ {
+ var settings = new ConnectionSettings { EndpointUrl = "" };
+ Should.Throw(() => settings.Validate())
+ .ParamName.ShouldBe("EndpointUrl");
+ }
+
+ [Fact]
+ public void Validate_ThrowsOnWhitespaceEndpointUrl()
+ {
+ var settings = new ConnectionSettings { EndpointUrl = " " };
+ Should.Throw(() => settings.Validate())
+ .ParamName.ShouldBe("EndpointUrl");
+ }
+
+ [Fact]
+ public void Validate_ThrowsOnZeroTimeout()
+ {
+ var settings = new ConnectionSettings
+ {
+ EndpointUrl = "opc.tcp://localhost:4840",
+ SessionTimeoutSeconds = 0
+ };
+ Should.Throw(() => settings.Validate())
+ .ParamName.ShouldBe("SessionTimeoutSeconds");
+ }
+
+ [Fact]
+ public void Validate_ThrowsOnNegativeTimeout()
+ {
+ var settings = new ConnectionSettings
+ {
+ EndpointUrl = "opc.tcp://localhost:4840",
+ SessionTimeoutSeconds = -1
+ };
+ Should.Throw(() => settings.Validate())
+ .ParamName.ShouldBe("SessionTimeoutSeconds");
+ }
+
+ [Fact]
+ public void Validate_ThrowsOnTimeoutAbove3600()
+ {
+ var settings = new ConnectionSettings
+ {
+ EndpointUrl = "opc.tcp://localhost:4840",
+ SessionTimeoutSeconds = 3601
+ };
+ Should.Throw(() => settings.Validate())
+ .ParamName.ShouldBe("SessionTimeoutSeconds");
+ }
+
+ [Fact]
+ public void Validate_SucceedsWithValidSettings()
+ {
+ var settings = new ConnectionSettings
+ {
+ EndpointUrl = "opc.tcp://localhost:4840",
+ SessionTimeoutSeconds = 120
+ };
+ Should.NotThrow(() => settings.Validate());
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
new file mode 100644
index 0000000..f6669a6
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
@@ -0,0 +1,129 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Models;
+
+public class ModelConstructionTests
+{
+ [Fact]
+ public void BrowseResult_ConstructsCorrectly()
+ {
+ var result = new ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult("ns=2;s=MyNode", "MyNode", "Variable", true);
+
+ result.NodeId.ShouldBe("ns=2;s=MyNode");
+ result.DisplayName.ShouldBe("MyNode");
+ result.NodeClass.ShouldBe("Variable");
+ result.HasChildren.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void BrowseResult_WithoutChildren()
+ {
+ var result = new ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult("ns=2;s=Leaf", "Leaf", "Variable", false);
+ result.HasChildren.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AlarmEventArgs_ConstructsCorrectly()
+ {
+ var time = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc);
+ var args = new AlarmEventArgs("Source1", "HighTemp", 500, "Temperature high", true, true, false, time);
+
+ args.SourceName.ShouldBe("Source1");
+ args.ConditionName.ShouldBe("HighTemp");
+ args.Severity.ShouldBe((ushort)500);
+ args.Message.ShouldBe("Temperature high");
+ args.Retain.ShouldBeTrue();
+ args.ActiveState.ShouldBeTrue();
+ args.AckedState.ShouldBeFalse();
+ args.Time.ShouldBe(time);
+ }
+
+ [Fact]
+ public void RedundancyInfo_ConstructsCorrectly()
+ {
+ var uris = new[] { "urn:server1", "urn:server2" };
+ var info = new RedundancyInfo("Warm", 200, uris, "urn:server1");
+
+ info.Mode.ShouldBe("Warm");
+ info.ServiceLevel.ShouldBe((byte)200);
+ info.ServerUris.ShouldBe(uris);
+ info.ApplicationUri.ShouldBe("urn:server1");
+ }
+
+ [Fact]
+ public void RedundancyInfo_WithEmptyUris()
+ {
+ var info = new RedundancyInfo("None", 0, Array.Empty(), string.Empty);
+ info.ServerUris.ShouldBeEmpty();
+ info.ApplicationUri.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void DataChangedEventArgs_ConstructsCorrectly()
+ {
+ var value = new DataValue(new Variant(42), StatusCodes.Good);
+ var args = new DataChangedEventArgs("ns=2;s=Temp", value);
+
+ args.NodeId.ShouldBe("ns=2;s=Temp");
+ args.Value.ShouldBe(value);
+ args.Value.Value.ShouldBe(42);
+ }
+
+ [Fact]
+ public void ConnectionStateChangedEventArgs_ConstructsCorrectly()
+ {
+ var args = new ConnectionStateChangedEventArgs(
+ ConnectionState.Disconnected, ConnectionState.Connected, "opc.tcp://localhost:4840");
+
+ args.OldState.ShouldBe(ConnectionState.Disconnected);
+ args.NewState.ShouldBe(ConnectionState.Connected);
+ args.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
+ }
+
+ [Fact]
+ public void ConnectionInfo_ConstructsCorrectly()
+ {
+ var info = new ConnectionInfo(
+ "opc.tcp://localhost:4840",
+ "TestServer",
+ "None",
+ "http://opcfoundation.org/UA/SecurityPolicy#None",
+ "ns=0;i=12345",
+ "TestSession");
+
+ info.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
+ info.ServerName.ShouldBe("TestServer");
+ info.SecurityMode.ShouldBe("None");
+ info.SecurityPolicyUri.ShouldBe("http://opcfoundation.org/UA/SecurityPolicy#None");
+ info.SessionId.ShouldBe("ns=0;i=12345");
+ info.SessionName.ShouldBe("TestSession");
+ }
+
+ [Fact]
+ public void SecurityMode_Enum_HasExpectedValues()
+ {
+ Enum.GetValues().Length.ShouldBe(3);
+ ((int)SecurityMode.None).ShouldBe(0);
+ ((int)SecurityMode.Sign).ShouldBe(1);
+ ((int)SecurityMode.SignAndEncrypt).ShouldBe(2);
+ }
+
+ [Fact]
+ public void ConnectionState_Enum_HasExpectedValues()
+ {
+ Enum.GetValues().Length.ShouldBe(4);
+ ((int)ConnectionState.Disconnected).ShouldBe(0);
+ ((int)ConnectionState.Connecting).ShouldBe(1);
+ ((int)ConnectionState.Connected).ShouldBe(2);
+ ((int)ConnectionState.Reconnecting).ShouldBe(3);
+ }
+
+ [Fact]
+ public void AggregateType_Enum_HasExpectedValues()
+ {
+ Enum.GetValues().Length.ShouldBe(6);
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
new file mode 100644
index 0000000..f627076
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
@@ -0,0 +1,816 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests;
+
+public class OpcUaClientServiceTests : IDisposable
+{
+ private readonly FakeApplicationConfigurationFactory _configFactory = new();
+ private readonly FakeEndpointDiscovery _endpointDiscovery = new();
+ private readonly FakeSessionFactory _sessionFactory = new();
+ private readonly OpcUaClientService _service;
+
+ public OpcUaClientServiceTests()
+ {
+ _service = new OpcUaClientService(_configFactory, _endpointDiscovery, _sessionFactory);
+ }
+
+ public void Dispose()
+ {
+ _service.Dispose();
+ }
+
+ private ConnectionSettings ValidSettings(string url = "opc.tcp://localhost:4840") => new()
+ {
+ EndpointUrl = url,
+ SessionTimeoutSeconds = 60
+ };
+
+ // --- Connection tests ---
+
+ [Fact]
+ public async Task ConnectAsync_ValidSettings_ReturnsConnectionInfo()
+ {
+ var info = await _service.ConnectAsync(ValidSettings());
+
+ info.ShouldNotBeNull();
+ info.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
+ _service.IsConnected.ShouldBeTrue();
+ _service.CurrentConnectionInfo.ShouldBe(info);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_InvalidSettings_ThrowsBeforeCreatingSession()
+ {
+ var settings = new ConnectionSettings { EndpointUrl = "" };
+
+ await Should.ThrowAsync(() => _service.ConnectAsync(settings));
+ _sessionFactory.CreateCallCount.ShouldBe(0);
+ _service.IsConnected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ConnectAsync_PopulatesConnectionInfo()
+ {
+ var session = new FakeSessionAdapter
+ {
+ ServerName = "MyServer",
+ SecurityMode = "Sign",
+ SecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256",
+ SessionId = "ns=0;i=999",
+ SessionName = "TestSession"
+ };
+ _sessionFactory.EnqueueSession(session);
+
+ var info = await _service.ConnectAsync(ValidSettings());
+
+ info.ServerName.ShouldBe("MyServer");
+ info.SecurityMode.ShouldBe("Sign");
+ info.SecurityPolicyUri.ShouldBe("http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256");
+ info.SessionId.ShouldBe("ns=0;i=999");
+ info.SessionName.ShouldBe("TestSession");
+ }
+
+ [Fact]
+ public async Task ConnectAsync_RaisesConnectionStateChangedEvents()
+ {
+ var events = new List();
+ _service.ConnectionStateChanged += (_, e) => events.Add(e);
+
+ await _service.ConnectAsync(ValidSettings());
+
+ events.Count.ShouldBe(2);
+ events[0].OldState.ShouldBe(ConnectionState.Disconnected);
+ events[0].NewState.ShouldBe(ConnectionState.Connecting);
+ events[1].OldState.ShouldBe(ConnectionState.Connecting);
+ events[1].NewState.ShouldBe(ConnectionState.Connected);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_SessionFactoryFails_TransitionsToDisconnected()
+ {
+ _sessionFactory.ThrowOnCreate = true;
+ var events = new List();
+ _service.ConnectionStateChanged += (_, e) => events.Add(e);
+
+ await Should.ThrowAsync(() => _service.ConnectAsync(ValidSettings()));
+
+ _service.IsConnected.ShouldBeFalse();
+ events.Last().NewState.ShouldBe(ConnectionState.Disconnected);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_WithUsername_PassesThroughToFactory()
+ {
+ var settings = ValidSettings();
+ settings.Username = "admin";
+ settings.Password = "secret";
+
+ await _service.ConnectAsync(settings);
+
+ _configFactory.LastSettings!.Username.ShouldBe("admin");
+ _configFactory.LastSettings!.Password.ShouldBe("secret");
+ }
+
+ // --- Disconnect tests ---
+
+ [Fact]
+ public async Task DisconnectAsync_WhenConnected_ClosesSession()
+ {
+ await _service.ConnectAsync(ValidSettings());
+ var session = _sessionFactory.CreatedSessions[0];
+
+ await _service.DisconnectAsync();
+
+ session.Closed.ShouldBeTrue();
+ _service.IsConnected.ShouldBeFalse();
+ _service.CurrentConnectionInfo.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task DisconnectAsync_WhenNotConnected_IsIdempotent()
+ {
+ await _service.DisconnectAsync(); // Should not throw
+ _service.IsConnected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task DisconnectAsync_CalledTwice_IsIdempotent()
+ {
+ await _service.ConnectAsync(ValidSettings());
+ await _service.DisconnectAsync();
+ await _service.DisconnectAsync(); // Should not throw
+ }
+
+ // --- Read tests ---
+
+ [Fact]
+ public async Task ReadValueAsync_WhenConnected_ReturnsValue()
+ {
+ var session = new FakeSessionAdapter
+ {
+ ReadResponse = new DataValue(new Variant(42), StatusCodes.Good)
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"));
+
+ result.Value.ShouldBe(42);
+ session.ReadCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task ReadValueAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
+ }
+
+ [Fact]
+ public async Task ReadValueAsync_SessionThrows_PropagatesException()
+ {
+ var session = new FakeSessionAdapter { ThrowOnRead = true };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await Should.ThrowAsync(() =>
+ _service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
+ }
+
+ // --- Write tests ---
+
+ [Fact]
+ public async Task WriteValueAsync_WhenConnected_WritesValue()
+ {
+ var session = new FakeSessionAdapter
+ {
+ ReadResponse = new DataValue(new Variant(0), StatusCodes.Good),
+ WriteResponse = StatusCodes.Good
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42);
+
+ result.ShouldBe(StatusCodes.Good);
+ session.WriteCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task WriteValueAsync_StringValue_CoercesToTargetType()
+ {
+ var session = new FakeSessionAdapter
+ {
+ ReadResponse = new DataValue(new Variant(0), StatusCodes.Good) // int type
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42");
+
+ session.WriteCount.ShouldBe(1);
+ session.ReadCount.ShouldBe(1); // Read for type inference
+ }
+
+ [Fact]
+ public async Task WriteValueAsync_NonStringValue_WritesDirectly()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42);
+
+ session.WriteCount.ShouldBe(1);
+ session.ReadCount.ShouldBe(0); // No read for non-string values
+ }
+
+ [Fact]
+ public async Task WriteValueAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
+ }
+
+ // --- Browse tests ---
+
+ [Fact]
+ public async Task BrowseAsync_WhenConnected_ReturnsMappedResults()
+ {
+ var session = new FakeSessionAdapter
+ {
+ BrowseResponse = new ReferenceDescriptionCollection
+ {
+ new ReferenceDescription
+ {
+ NodeId = new ExpandedNodeId("ns=2;s=Child1"),
+ DisplayName = new LocalizedText("Child1"),
+ NodeClass = NodeClass.Variable
+ }
+ }
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var results = await _service.BrowseAsync();
+
+ results.Count.ShouldBe(1);
+ results[0].DisplayName.ShouldBe("Child1");
+ results[0].NodeClass.ShouldBe("Variable");
+ results[0].HasChildren.ShouldBeFalse(); // Variable nodes don't check HasChildren
+ }
+
+ [Fact]
+ public async Task BrowseAsync_NullParent_UsesObjectsFolder()
+ {
+ var session = new FakeSessionAdapter
+ {
+ BrowseResponse = new ReferenceDescriptionCollection()
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.BrowseAsync(null);
+
+ session.BrowseCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
+ {
+ var session = new FakeSessionAdapter
+ {
+ BrowseResponse = new ReferenceDescriptionCollection
+ {
+ new ReferenceDescription
+ {
+ NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
+ DisplayName = new LocalizedText("Folder1"),
+ NodeClass = NodeClass.Object
+ }
+ },
+ HasChildrenResponse = true
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var results = await _service.BrowseAsync();
+
+ results[0].HasChildren.ShouldBeTrue();
+ session.HasChildrenCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task BrowseAsync_WithContinuationPoint_FollowsIt()
+ {
+ var session = new FakeSessionAdapter
+ {
+ BrowseResponse = new ReferenceDescriptionCollection
+ {
+ new ReferenceDescription
+ {
+ NodeId = new ExpandedNodeId("ns=2;s=A"),
+ DisplayName = new LocalizedText("A"),
+ NodeClass = NodeClass.Variable
+ }
+ },
+ BrowseContinuationPoint = new byte[] { 1, 2, 3 },
+ BrowseNextResponse = new ReferenceDescriptionCollection
+ {
+ new ReferenceDescription
+ {
+ NodeId = new ExpandedNodeId("ns=2;s=B"),
+ DisplayName = new LocalizedText("B"),
+ NodeClass = NodeClass.Variable
+ }
+ },
+ BrowseNextContinuationPoint = null
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var results = await _service.BrowseAsync();
+
+ results.Count.ShouldBe(2);
+ session.BrowseNextCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task BrowseAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() => _service.BrowseAsync());
+ }
+
+ // --- Subscribe tests ---
+
+ [Fact]
+ public async Task SubscribeAsync_CreatesSubscription()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500);
+
+ session.CreatedSubscriptions.Count.ShouldBe(1);
+ session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task SubscribeAsync_DuplicateNode_IsIdempotent()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"));
+ await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode")); // duplicate
+
+ session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task SubscribeAsync_RaisesDataChangedEvent()
+ {
+ var fakeSub = new FakeSubscriptionAdapter();
+ var session = new FakeSessionAdapter { NextSubscription = fakeSub };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ DataChangedEventArgs? received = null;
+ _service.DataChanged += (_, e) => received = e;
+
+ await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500);
+
+ // Simulate data change
+ var handle = fakeSub.ActiveHandles.First();
+ fakeSub.SimulateDataChange(handle, new DataValue(new Variant(99), StatusCodes.Good));
+
+ received.ShouldNotBeNull();
+ received!.NodeId.ShouldBe("ns=2;s=MyNode");
+ received.Value.Value.ShouldBe(99);
+ }
+
+ [Fact]
+ public async Task UnsubscribeAsync_RemovesMonitoredItem()
+ {
+ var fakeSub = new FakeSubscriptionAdapter();
+ var session = new FakeSessionAdapter { NextSubscription = fakeSub };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"));
+ await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode"));
+
+ fakeSub.RemoveCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task UnsubscribeAsync_WhenNotSubscribed_DoesNotThrow()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"));
+ // Should not throw
+ }
+
+ [Fact]
+ public async Task SubscribeAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.SubscribeAsync(new NodeId("ns=2;s=MyNode")));
+ }
+
+ // --- Alarm subscription tests ---
+
+ [Fact]
+ public async Task SubscribeAlarmsAsync_CreatesEventSubscription()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAlarmsAsync(null, 1000);
+
+ session.CreatedSubscriptions.Count.ShouldBe(1);
+ session.CreatedSubscriptions[0].AddEventCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task SubscribeAlarmsAsync_Duplicate_IsIdempotent()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAlarmsAsync();
+ await _service.SubscribeAlarmsAsync(); // duplicate
+
+ session.CreatedSubscriptions.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task SubscribeAlarmsAsync_RaisesAlarmEvent()
+ {
+ var fakeSub = new FakeSubscriptionAdapter();
+ var session = new FakeSessionAdapter { NextSubscription = fakeSub };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ AlarmEventArgs? received = null;
+ _service.AlarmEvent += (_, e) => received = e;
+
+ await _service.SubscribeAlarmsAsync();
+
+ // Simulate alarm event with proper field count
+ var handle = fakeSub.ActiveHandles.First();
+ var fields = new EventFieldList
+ {
+ EventFields = new VariantCollection
+ {
+ new Variant(new byte[] { 1, 2, 3 }), // 0: EventId
+ new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
+ new Variant("Source1"), // 2: SourceName
+ new Variant(DateTime.UtcNow), // 3: Time
+ new Variant(new LocalizedText("High temp")), // 4: Message
+ new Variant((ushort)500), // 5: Severity
+ new Variant("HighTemp"), // 6: ConditionName
+ new Variant(true), // 7: Retain
+ new Variant(false), // 8: AckedState
+ new Variant(true), // 9: ActiveState
+ new Variant(true), // 10: EnabledState
+ new Variant(false) // 11: SuppressedOrShelved
+ }
+ };
+ fakeSub.SimulateEvent(handle, fields);
+
+ received.ShouldNotBeNull();
+ received!.SourceName.ShouldBe("Source1");
+ received.ConditionName.ShouldBe("HighTemp");
+ received.Severity.ShouldBe((ushort)500);
+ received.Message.ShouldBe("High temp");
+ received.Retain.ShouldBeTrue();
+ received.ActiveState.ShouldBeTrue();
+ received.AckedState.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task UnsubscribeAlarmsAsync_DeletesSubscription()
+ {
+ var fakeSub = new FakeSubscriptionAdapter();
+ var session = new FakeSessionAdapter { NextSubscription = fakeSub };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAlarmsAsync();
+ await _service.UnsubscribeAlarmsAsync();
+
+ fakeSub.Deleted.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task UnsubscribeAlarmsAsync_WhenNoSubscription_DoesNotThrow()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.UnsubscribeAlarmsAsync(); // Should not throw
+ }
+
+ [Fact]
+ public async Task RequestConditionRefreshAsync_CallsAdapter()
+ {
+ var fakeSub = new FakeSubscriptionAdapter();
+ var session = new FakeSessionAdapter { NextSubscription = fakeSub };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await _service.SubscribeAlarmsAsync();
+ await _service.RequestConditionRefreshAsync();
+
+ fakeSub.ConditionRefreshCalled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task RequestConditionRefreshAsync_NoAlarmSubscription_Throws()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await Should.ThrowAsync(() =>
+ _service.RequestConditionRefreshAsync());
+ }
+
+ [Fact]
+ public async Task SubscribeAlarmsAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.SubscribeAlarmsAsync());
+ }
+
+ // --- History read tests ---
+
+ [Fact]
+ public async Task HistoryReadRawAsync_ReturnsValues()
+ {
+ var expectedValues = new List
+ {
+ new DataValue(new Variant(1.0), StatusCodes.Good),
+ new DataValue(new Variant(2.0), StatusCodes.Good)
+ };
+ var session = new FakeSessionAdapter { HistoryReadRawResponse = expectedValues };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var results = await _service.HistoryReadRawAsync(
+ new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow);
+
+ results.Count.ShouldBe(2);
+ session.HistoryReadRawCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
+ }
+
+ [Fact]
+ public async Task HistoryReadRawAsync_SessionThrows_PropagatesException()
+ {
+ var session = new FakeSessionAdapter { ThrowOnHistoryReadRaw = true };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await Should.ThrowAsync(() =>
+ _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
+ }
+
+ [Fact]
+ public async Task HistoryReadAggregateAsync_ReturnsValues()
+ {
+ var expectedValues = new List
+ {
+ new DataValue(new Variant(1.5), StatusCodes.Good)
+ };
+ var session = new FakeSessionAdapter { HistoryReadAggregateResponse = expectedValues };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var results = await _service.HistoryReadAggregateAsync(
+ new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
+ AggregateType.Average);
+
+ results.Count.ShouldBe(1);
+ session.HistoryReadAggregateCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task HistoryReadAggregateAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.HistoryReadAggregateAsync(
+ new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
+ AggregateType.Average));
+ }
+
+ [Fact]
+ public async Task HistoryReadAggregateAsync_SessionThrows_PropagatesException()
+ {
+ var session = new FakeSessionAdapter { ThrowOnHistoryReadAggregate = true };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ await Should.ThrowAsync(() =>
+ _service.HistoryReadAggregateAsync(
+ new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
+ AggregateType.Average));
+ }
+
+ // --- Redundancy tests ---
+
+ [Fact]
+ public async Task GetRedundancyInfoAsync_ReturnsInfo()
+ {
+ var session = new FakeSessionAdapter
+ {
+ ReadResponseFunc = nodeId =>
+ {
+ if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
+ return new DataValue(new Variant((int)RedundancySupport.Warm), StatusCodes.Good);
+ if (nodeId == VariableIds.Server_ServiceLevel)
+ return new DataValue(new Variant((byte)200), StatusCodes.Good);
+ if (nodeId == VariableIds.Server_ServerRedundancy_ServerUriArray)
+ return new DataValue(new Variant(new[] { "urn:server1", "urn:server2" }), StatusCodes.Good);
+ if (nodeId == VariableIds.Server_ServerArray)
+ return new DataValue(new Variant(new[] { "urn:server1" }), StatusCodes.Good);
+ return new DataValue(StatusCodes.BadNodeIdUnknown);
+ }
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var info = await _service.GetRedundancyInfoAsync();
+
+ info.Mode.ShouldBe("Warm");
+ info.ServiceLevel.ShouldBe((byte)200);
+ info.ServerUris.ShouldBe(new[] { "urn:server1", "urn:server2" });
+ info.ApplicationUri.ShouldBe("urn:server1");
+ }
+
+ [Fact]
+ public async Task GetRedundancyInfoAsync_MissingOptionalArrays_ReturnsGracefully()
+ {
+ int readCallIndex = 0;
+ var session = new FakeSessionAdapter
+ {
+ ReadResponseFunc = nodeId =>
+ {
+ if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
+ return new DataValue(new Variant((int)RedundancySupport.None), StatusCodes.Good);
+ if (nodeId == VariableIds.Server_ServiceLevel)
+ return new DataValue(new Variant((byte)100), StatusCodes.Good);
+ // Throw for optional reads
+ throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
+ }
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ var info = await _service.GetRedundancyInfoAsync();
+
+ info.Mode.ShouldBe("None");
+ info.ServiceLevel.ShouldBe((byte)100);
+ info.ServerUris.ShouldBeEmpty();
+ info.ApplicationUri.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetRedundancyInfoAsync_WhenDisconnected_Throws()
+ {
+ await Should.ThrowAsync(() =>
+ _service.GetRedundancyInfoAsync());
+ }
+
+ // --- Failover tests ---
+
+ [Fact]
+ public async Task KeepAliveFailure_TriggersFailover()
+ {
+ var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
+ var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840" };
+ _sessionFactory.EnqueueSession(session1);
+ _sessionFactory.EnqueueSession(session2);
+
+ var settings = ValidSettings("opc.tcp://primary:4840");
+ settings.FailoverUrls = new[] { "opc.tcp://backup:4840" };
+
+ var stateChanges = new List();
+ _service.ConnectionStateChanged += (_, e) => stateChanges.Add(e);
+
+ await _service.ConnectAsync(settings);
+
+ // Simulate keep-alive failure
+ session1.SimulateKeepAlive(false);
+
+ // Give async failover time to complete
+ await Task.Delay(200);
+
+ // Should have reconnected
+ stateChanges.ShouldContain(e => e.NewState == ConnectionState.Reconnecting);
+ stateChanges.ShouldContain(e => e.NewState == ConnectionState.Connected &&
+ e.EndpointUrl == "opc.tcp://backup:4840");
+ }
+
+ [Fact]
+ public async Task KeepAliveFailure_UpdatesConnectionInfo()
+ {
+ var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
+ var session2 = new FakeSessionAdapter
+ {
+ EndpointUrl = "opc.tcp://backup:4840",
+ ServerName = "BackupServer"
+ };
+ _sessionFactory.EnqueueSession(session1);
+ _sessionFactory.EnqueueSession(session2);
+
+ var settings = ValidSettings("opc.tcp://primary:4840");
+ settings.FailoverUrls = new[] { "opc.tcp://backup:4840" };
+
+ await _service.ConnectAsync(settings);
+ session1.SimulateKeepAlive(false);
+ await Task.Delay(200);
+
+ _service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
+ _service.CurrentConnectionInfo.ServerName.ShouldBe("BackupServer");
+ }
+
+ [Fact]
+ public async Task KeepAliveFailure_AllEndpointsFail_TransitionsToDisconnected()
+ {
+ var session1 = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session1);
+
+ await _service.ConnectAsync(ValidSettings());
+
+ // After the first session, make factory fail
+ _sessionFactory.ThrowOnCreate = true;
+ session1.SimulateKeepAlive(false);
+ await Task.Delay(200);
+
+ _service.IsConnected.ShouldBeFalse();
+ }
+
+ // --- Dispose tests ---
+
+ [Fact]
+ public async Task Dispose_CleansUpResources()
+ {
+ var session = new FakeSessionAdapter();
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ _service.Dispose();
+
+ session.Disposed.ShouldBeTrue();
+ _service.CurrentConnectionInfo.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Dispose_WhenNotConnected_DoesNotThrow()
+ {
+ _service.Dispose(); // Should not throw
+ }
+
+ [Fact]
+ public async Task OperationsAfterDispose_Throw()
+ {
+ _service.Dispose();
+
+ await Should.ThrowAsync(() =>
+ _service.ConnectAsync(ValidSettings()));
+ await Should.ThrowAsync(() =>
+ _service.ReadValueAsync(new NodeId("ns=2;s=X")));
+ }
+
+ // --- Factory tests ---
+
+ [Fact]
+ public void OpcUaClientServiceFactory_CreatesService()
+ {
+ var factory = new OpcUaClientServiceFactory();
+ var service = factory.Create();
+ service.ShouldNotBeNull();
+ service.ShouldBeAssignableTo();
+ service.Dispose();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.csproj b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.csproj
new file mode 100644
index 0000000..70f9a52
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests/ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs
new file mode 100644
index 0000000..d7a1c54
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs
@@ -0,0 +1,141 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
+
+public class AlarmsViewModelTests
+{
+ private readonly FakeOpcUaClientService _service;
+ private readonly AlarmsViewModel _vm;
+
+ public AlarmsViewModelTests()
+ {
+ _service = new FakeOpcUaClientService();
+ var dispatcher = new SynchronousUiDispatcher();
+ _vm = new AlarmsViewModel(_service, dispatcher);
+ }
+
+ [Fact]
+ public void SubscribeCommand_CannotExecute_WhenDisconnected()
+ {
+ _vm.IsConnected = false;
+ _vm.SubscribeCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void SubscribeCommand_CannotExecute_WhenAlreadySubscribed()
+ {
+ _vm.IsConnected = true;
+ _vm.IsSubscribed = true;
+ _vm.SubscribeCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void SubscribeCommand_CanExecute_WhenConnectedAndNotSubscribed()
+ {
+ _vm.IsConnected = true;
+ _vm.IsSubscribed = false;
+ _vm.SubscribeCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task SubscribeCommand_SetsIsSubscribed()
+ {
+ _vm.IsConnected = true;
+
+ await _vm.SubscribeCommand.ExecuteAsync(null);
+
+ _vm.IsSubscribed.ShouldBeTrue();
+ _service.SubscribeAlarmsCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public void UnsubscribeCommand_CannotExecute_WhenNotSubscribed()
+ {
+ _vm.IsConnected = true;
+ _vm.IsSubscribed = false;
+ _vm.UnsubscribeCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task UnsubscribeCommand_ClearsIsSubscribed()
+ {
+ _vm.IsConnected = true;
+ await _vm.SubscribeCommand.ExecuteAsync(null);
+
+ await _vm.UnsubscribeCommand.ExecuteAsync(null);
+
+ _vm.IsSubscribed.ShouldBeFalse();
+ _service.UnsubscribeAlarmsCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task RefreshCommand_CallsService()
+ {
+ _vm.IsConnected = true;
+ _vm.IsSubscribed = true;
+
+ await _vm.RefreshCommand.ExecuteAsync(null);
+
+ _service.RequestConditionRefreshCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public void RefreshCommand_CannotExecute_WhenNotSubscribed()
+ {
+ _vm.IsConnected = true;
+ _vm.IsSubscribed = false;
+ _vm.RefreshCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AlarmEvent_AddsToCollection()
+ {
+ var alarm = new AlarmEventArgs(
+ "Source1", "HighAlarm", 500, "Temperature high",
+ true, true, false, DateTime.UtcNow);
+
+ _service.RaiseAlarmEvent(alarm);
+
+ _vm.AlarmEvents.Count.ShouldBe(1);
+ _vm.AlarmEvents[0].SourceName.ShouldBe("Source1");
+ _vm.AlarmEvents[0].ConditionName.ShouldBe("HighAlarm");
+ _vm.AlarmEvents[0].Severity.ShouldBe((ushort)500);
+ _vm.AlarmEvents[0].Message.ShouldBe("Temperature high");
+ }
+
+ [Fact]
+ public void Clear_ResetsState()
+ {
+ _vm.IsSubscribed = true;
+ _vm.AlarmEvents.Add(new AlarmEventViewModel("Src", "Cond", 100, "Msg", true, true, false, DateTime.UtcNow));
+
+ _vm.Clear();
+
+ _vm.AlarmEvents.ShouldBeEmpty();
+ _vm.IsSubscribed.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Teardown_UnhooksEventHandler()
+ {
+ _vm.Teardown();
+
+ var alarm = new AlarmEventArgs(
+ "Source1", "HighAlarm", 500, "Test",
+ true, true, false, DateTime.UtcNow);
+ _service.RaiseAlarmEvent(alarm);
+
+ _vm.AlarmEvents.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void DefaultInterval_Is1000()
+ {
+ _vm.Interval.ShouldBe(1000);
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/BrowseTreeViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/BrowseTreeViewModelTests.cs
new file mode 100644
index 0000000..0226908
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/BrowseTreeViewModelTests.cs
@@ -0,0 +1,155 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
+
+public class BrowseTreeViewModelTests
+{
+ private readonly FakeOpcUaClientService _service;
+ private readonly SynchronousUiDispatcher _dispatcher;
+ private readonly BrowseTreeViewModel _vm;
+
+ public BrowseTreeViewModelTests()
+ {
+ _service = new FakeOpcUaClientService
+ {
+ BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Node1", "Node1", "Object", true),
+ new BrowseResult("ns=2;s=Node2", "Node2", "Variable", false)
+ }
+ };
+ _dispatcher = new SynchronousUiDispatcher();
+ _vm = new BrowseTreeViewModel(_service, _dispatcher);
+ }
+
+ [Fact]
+ public async Task LoadRootsAsync_PopulatesRootNodes()
+ {
+ await _vm.LoadRootsAsync();
+
+ _vm.RootNodes.Count.ShouldBe(2);
+ _vm.RootNodes[0].DisplayName.ShouldBe("Node1");
+ _vm.RootNodes[1].DisplayName.ShouldBe("Node2");
+ }
+
+ [Fact]
+ public async Task LoadRootsAsync_BrowsesWithNullParent()
+ {
+ await _vm.LoadRootsAsync();
+
+ _service.BrowseCallCount.ShouldBe(1);
+ _service.LastBrowseParentNodeId.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Clear_RemovesAllRootNodes()
+ {
+ _vm.RootNodes.Add(new TreeNodeViewModel("ns=2;s=X", "X", "Object", false, _service, _dispatcher));
+ _vm.Clear();
+
+ _vm.RootNodes.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task LoadRootsAsync_NodeWithChildren_HasPlaceholder()
+ {
+ await _vm.LoadRootsAsync();
+
+ var nodeWithChildren = _vm.RootNodes[0];
+ nodeWithChildren.HasChildren.ShouldBeTrue();
+ nodeWithChildren.Children.Count.ShouldBe(1);
+ nodeWithChildren.Children[0].IsPlaceholder.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task LoadRootsAsync_NodeWithoutChildren_HasNoPlaceholder()
+ {
+ await _vm.LoadRootsAsync();
+
+ var leafNode = _vm.RootNodes[1];
+ leafNode.HasChildren.ShouldBeFalse();
+ leafNode.Children.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task TreeNode_FirstExpand_TriggersChildBrowse()
+ {
+ _service.BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Parent", "Parent", "Object", true)
+ };
+
+ await _vm.LoadRootsAsync();
+
+ // Reset browse results for child browse
+ _service.BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Child1", "Child1", "Variable", false)
+ };
+
+ var parent = _vm.RootNodes[0];
+ var initialBrowseCount = _service.BrowseCallCount;
+
+ parent.IsExpanded = true;
+
+ // Allow async operation to complete
+ await Task.Delay(50);
+
+ _service.BrowseCallCount.ShouldBe(initialBrowseCount + 1);
+ parent.Children.Count.ShouldBe(1);
+ parent.Children[0].DisplayName.ShouldBe("Child1");
+ }
+
+ [Fact]
+ public async Task TreeNode_SecondExpand_DoesNotBrowseAgain()
+ {
+ _service.BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Parent", "Parent", "Object", true)
+ };
+
+ await _vm.LoadRootsAsync();
+
+ _service.BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Child1", "Child1", "Variable", false)
+ };
+
+ var parent = _vm.RootNodes[0];
+ parent.IsExpanded = true;
+ await Task.Delay(50);
+
+ var browseCountAfterFirst = _service.BrowseCallCount;
+
+ parent.IsExpanded = false;
+ parent.IsExpanded = true;
+ await Task.Delay(50);
+
+ _service.BrowseCallCount.ShouldBe(browseCountAfterFirst);
+ }
+
+ [Fact]
+ public async Task TreeNode_IsLoading_TransitionsDuringBrowse()
+ {
+ _service.BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Parent", "Parent", "Object", true)
+ };
+
+ await _vm.LoadRootsAsync();
+
+ _service.BrowseResults = Array.Empty();
+
+ var parent = _vm.RootNodes[0];
+ parent.IsExpanded = true;
+ await Task.Delay(50);
+
+ // After completion, IsLoading should be false
+ parent.IsLoading.ShouldBeFalse();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
new file mode 100644
index 0000000..56515b1
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
@@ -0,0 +1,166 @@
+using Opc.Ua;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
+using ConnectionState = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.ConnectionState;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+
+///
+/// Fake IOpcUaClientService for unit testing.
+///
+public sealed class FakeOpcUaClientService : IOpcUaClientService
+{
+ // Configurable responses
+ public ConnectionInfo? ConnectResult { get; set; }
+ public Exception? ConnectException { get; set; }
+ public IReadOnlyList BrowseResults { get; set; } = Array.Empty();
+ public Exception? BrowseException { get; set; }
+ public DataValue ReadResult { get; set; } = new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow);
+ public Exception? ReadException { get; set; }
+ public StatusCode WriteResult { get; set; } = StatusCodes.Good;
+ public Exception? WriteException { get; set; }
+ public RedundancyInfo? RedundancyResult { get; set; }
+ public Exception? RedundancyException { get; set; }
+ public IReadOnlyList HistoryRawResult { get; set; } = Array.Empty();
+ public IReadOnlyList HistoryAggregateResult { get; set; } = Array.Empty();
+ public Exception? HistoryException { get; set; }
+
+ // Call tracking
+ public int ConnectCallCount { get; private set; }
+ public int DisconnectCallCount { get; private set; }
+ public int ReadCallCount { get; private set; }
+ public int WriteCallCount { get; private set; }
+ public int BrowseCallCount { get; private set; }
+ public int SubscribeCallCount { get; private set; }
+ public int UnsubscribeCallCount { get; private set; }
+ public int SubscribeAlarmsCallCount { get; private set; }
+ public int UnsubscribeAlarmsCallCount { get; private set; }
+ public int RequestConditionRefreshCallCount { get; private set; }
+ public int HistoryReadRawCallCount { get; private set; }
+ public int HistoryReadAggregateCallCount { get; private set; }
+ public int GetRedundancyInfoCallCount { get; private set; }
+
+ public NodeId? LastReadNodeId { get; private set; }
+ public NodeId? LastWriteNodeId { get; private set; }
+ public object? LastWriteValue { get; private set; }
+ public NodeId? LastBrowseParentNodeId { get; private set; }
+ public NodeId? LastSubscribeNodeId { get; private set; }
+ public int LastSubscribeIntervalMs { get; private set; }
+ public NodeId? LastUnsubscribeNodeId { get; private set; }
+ public AggregateType? LastAggregateType { get; private set; }
+
+ public bool IsConnected { get; set; }
+ public ConnectionInfo? CurrentConnectionInfo { get; set; }
+
+ public event EventHandler? DataChanged;
+ public event EventHandler? AlarmEvent;
+ public event EventHandler? ConnectionStateChanged;
+
+ public Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
+ {
+ ConnectCallCount++;
+ if (ConnectException != null) throw ConnectException;
+ IsConnected = true;
+ CurrentConnectionInfo = ConnectResult;
+ return Task.FromResult(ConnectResult!);
+ }
+
+ public Task DisconnectAsync(CancellationToken ct = default)
+ {
+ DisconnectCallCount++;
+ IsConnected = false;
+ CurrentConnectionInfo = null;
+ return Task.CompletedTask;
+ }
+
+ public Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
+ {
+ ReadCallCount++;
+ LastReadNodeId = nodeId;
+ if (ReadException != null) throw ReadException;
+ return Task.FromResult(ReadResult);
+ }
+
+ public Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
+ {
+ WriteCallCount++;
+ LastWriteNodeId = nodeId;
+ LastWriteValue = value;
+ if (WriteException != null) throw WriteException;
+ return Task.FromResult(WriteResult);
+ }
+
+ public Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
+ {
+ BrowseCallCount++;
+ LastBrowseParentNodeId = parentNodeId;
+ if (BrowseException != null) throw BrowseException;
+ return Task.FromResult(BrowseResults);
+ }
+
+ public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
+ {
+ SubscribeCallCount++;
+ LastSubscribeNodeId = nodeId;
+ LastSubscribeIntervalMs = intervalMs;
+ return Task.CompletedTask;
+ }
+
+ public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
+ {
+ UnsubscribeCallCount++;
+ LastUnsubscribeNodeId = nodeId;
+ return Task.CompletedTask;
+ }
+
+ public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
+ {
+ SubscribeAlarmsCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
+ {
+ UnsubscribeAlarmsCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task RequestConditionRefreshAsync(CancellationToken ct = default)
+ {
+ RequestConditionRefreshCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
+ {
+ HistoryReadRawCallCount++;
+ if (HistoryException != null) throw HistoryException;
+ return Task.FromResult(HistoryRawResult);
+ }
+
+ public Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default)
+ {
+ HistoryReadAggregateCallCount++;
+ LastAggregateType = aggregate;
+ if (HistoryException != null) throw HistoryException;
+ return Task.FromResult(HistoryAggregateResult);
+ }
+
+ public Task GetRedundancyInfoAsync(CancellationToken ct = default)
+ {
+ GetRedundancyInfoCallCount++;
+ if (RedundancyException != null) throw RedundancyException;
+ return Task.FromResult(RedundancyResult!);
+ }
+
+ // Methods to raise events from tests
+ public void RaiseDataChanged(DataChangedEventArgs args) => DataChanged?.Invoke(this, args);
+ public void RaiseAlarmEvent(AlarmEventArgs args) => AlarmEvent?.Invoke(this, args);
+ public void RaiseConnectionStateChanged(ConnectionStateChangedEventArgs args) => ConnectionStateChanged?.Invoke(this, args);
+
+ public void Dispose()
+ {
+ // No-op for testing
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientServiceFactory.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientServiceFactory.cs
new file mode 100644
index 0000000..5c15887
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientServiceFactory.cs
@@ -0,0 +1,18 @@
+using ZB.MOM.WW.LmxOpcUa.Client.Shared;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+
+///
+/// Fake factory that returns a preconfigured FakeOpcUaClientService.
+///
+public sealed class FakeOpcUaClientServiceFactory : IOpcUaClientServiceFactory
+{
+ private readonly FakeOpcUaClientService _service;
+
+ public FakeOpcUaClientServiceFactory(FakeOpcUaClientService service)
+ {
+ _service = service;
+ }
+
+ public IOpcUaClientService Create() => _service;
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/HistoryViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/HistoryViewModelTests.cs
new file mode 100644
index 0000000..efe9c14
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/HistoryViewModelTests.cs
@@ -0,0 +1,160 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
+
+public class HistoryViewModelTests
+{
+ private readonly FakeOpcUaClientService _service;
+ private readonly HistoryViewModel _vm;
+
+ public HistoryViewModelTests()
+ {
+ _service = new FakeOpcUaClientService
+ {
+ HistoryRawResult = new[]
+ {
+ new DataValue(new Variant(10), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow),
+ new DataValue(new Variant(20), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
+ },
+ HistoryAggregateResult = new[]
+ {
+ new DataValue(new Variant(15.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
+ }
+ };
+ var dispatcher = new SynchronousUiDispatcher();
+ _vm = new HistoryViewModel(_service, dispatcher);
+ }
+
+ [Fact]
+ public void ReadHistoryCommand_CannotExecute_WhenDisconnected()
+ {
+ _vm.IsConnected = false;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.ReadHistoryCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ReadHistoryCommand_CannotExecute_WhenNoNodeSelected()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = null;
+ _vm.ReadHistoryCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ReadHistoryCommand_CanExecute_WhenConnectedAndNodeSelected()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.ReadHistoryCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ReadHistoryCommand_Raw_PopulatesResults()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.SelectedAggregateType = null; // Raw
+
+ await _vm.ReadHistoryCommand.ExecuteAsync(null);
+
+ _vm.Results.Count.ShouldBe(2);
+ _vm.Results[0].Value.ShouldBe("10");
+ _vm.Results[1].Value.ShouldBe("20");
+ _service.HistoryReadRawCallCount.ShouldBe(1);
+ _service.HistoryReadAggregateCallCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task ReadHistoryCommand_Aggregate_PopulatesResults()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.SelectedAggregateType = AggregateType.Average;
+
+ await _vm.ReadHistoryCommand.ExecuteAsync(null);
+
+ _vm.Results.Count.ShouldBe(1);
+ _vm.Results[0].Value.ShouldBe("15");
+ _service.HistoryReadAggregateCallCount.ShouldBe(1);
+ _service.LastAggregateType.ShouldBe(AggregateType.Average);
+ _service.HistoryReadRawCallCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task ReadHistoryCommand_ClearsResultsBefore()
+ {
+ _vm.Results.Add(new HistoryValueViewModel("old", "Good", "t1", "t2"));
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+
+ await _vm.ReadHistoryCommand.ExecuteAsync(null);
+
+ _vm.Results.ShouldNotContain(r => r.Value == "old");
+ }
+
+ [Fact]
+ public async Task ReadHistoryCommand_IsLoading_FalseAfterComplete()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+
+ await _vm.ReadHistoryCommand.ExecuteAsync(null);
+
+ _vm.IsLoading.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void DefaultValues_AreCorrect()
+ {
+ _vm.MaxValues.ShouldBe(1000);
+ _vm.IntervalMs.ShouldBe(3600000);
+ _vm.SelectedAggregateType.ShouldBeNull();
+ _vm.IsAggregateRead.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsAggregateRead_TrueWhenAggregateSelected()
+ {
+ _vm.SelectedAggregateType = AggregateType.Maximum;
+ _vm.IsAggregateRead.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AggregateTypes_ContainsNullForRaw()
+ {
+ _vm.AggregateTypes.ShouldContain((AggregateType?)null);
+ _vm.AggregateTypes.Count.ShouldBe(7); // null + 6 enum values
+ }
+
+ [Fact]
+ public void Clear_ResetsState()
+ {
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.Results.Add(new HistoryValueViewModel("v", "s", "t1", "t2"));
+
+ _vm.Clear();
+
+ _vm.Results.ShouldBeEmpty();
+ _vm.SelectedNodeId.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ReadHistoryCommand_Error_ShowsErrorInResults()
+ {
+ _service.HistoryException = new Exception("History not supported");
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+
+ await _vm.ReadHistoryCommand.ExecuteAsync(null);
+
+ _vm.Results.Count.ShouldBe(1);
+ _vm.Results[0].Value.ShouldContain("History not supported");
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs
new file mode 100644
index 0000000..7090d5a
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs
@@ -0,0 +1,190 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
+using ConnectionState = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.ConnectionState;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
+
+public class MainWindowViewModelTests
+{
+ private readonly FakeOpcUaClientService _service;
+ private readonly MainWindowViewModel _vm;
+
+ public MainWindowViewModelTests()
+ {
+ _service = new FakeOpcUaClientService
+ {
+ ConnectResult = new ConnectionInfo(
+ "opc.tcp://localhost:4840",
+ "TestServer",
+ "None",
+ "http://opcfoundation.org/UA/SecurityPolicy#None",
+ "session-1",
+ "TestSession"),
+ BrowseResults = new[]
+ {
+ new BrowseResult("ns=2;s=Root", "Root", "Object", true)
+ },
+ RedundancyResult = new RedundancyInfo("None", 200, new[] { "urn:test" }, "urn:test")
+ };
+
+ var factory = new FakeOpcUaClientServiceFactory(_service);
+ var dispatcher = new SynchronousUiDispatcher();
+ _vm = new MainWindowViewModel(factory, dispatcher);
+ }
+
+ [Fact]
+ public void DefaultState_IsDisconnected()
+ {
+ _vm.ConnectionState.ShouldBe(ConnectionState.Disconnected);
+ _vm.IsConnected.ShouldBeFalse();
+ _vm.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
+ _vm.StatusMessage.ShouldBe("Disconnected");
+ }
+
+ [Fact]
+ public void ConnectCommand_CanExecute_WhenDisconnected()
+ {
+ _vm.ConnectCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void DisconnectCommand_CannotExecute_WhenDisconnected()
+ {
+ _vm.DisconnectCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ConnectCommand_TransitionsToConnected()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _vm.ConnectionState.ShouldBe(ConnectionState.Connected);
+ _vm.IsConnected.ShouldBeTrue();
+ _service.ConnectCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task ConnectCommand_LoadsRootNodes()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _vm.BrowseTree.RootNodes.Count.ShouldBe(1);
+ _vm.BrowseTree.RootNodes[0].DisplayName.ShouldBe("Root");
+ }
+
+ [Fact]
+ public async Task ConnectCommand_FetchesRedundancyInfo()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _vm.RedundancyInfo.ShouldNotBeNull();
+ _vm.RedundancyInfo!.Mode.ShouldBe("None");
+ _vm.RedundancyInfo.ServiceLevel.ShouldBe((byte)200);
+ }
+
+ [Fact]
+ public async Task ConnectCommand_SetsSessionLabel()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _vm.SessionLabel.ShouldContain("TestServer");
+ _vm.SessionLabel.ShouldContain("TestSession");
+ }
+
+ [Fact]
+ public async Task DisconnectCommand_TransitionsToDisconnected()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+ await _vm.DisconnectCommand.ExecuteAsync(null);
+
+ _vm.ConnectionState.ShouldBe(ConnectionState.Disconnected);
+ _vm.IsConnected.ShouldBeFalse();
+ _service.DisconnectCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Disconnect_ClearsStateAndChildren()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+ await _vm.DisconnectCommand.ExecuteAsync(null);
+
+ _vm.SessionLabel.ShouldBe(string.Empty);
+ _vm.RedundancyInfo.ShouldBeNull();
+ _vm.BrowseTree.RootNodes.ShouldBeEmpty();
+ _vm.SubscriptionCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public void ConnectionStateChangedEvent_UpdatesState()
+ {
+ _service.RaiseConnectionStateChanged(
+ new ConnectionStateChangedEventArgs(ConnectionState.Disconnected, ConnectionState.Reconnecting, "opc.tcp://localhost:4840"));
+
+ _vm.ConnectionState.ShouldBe(ConnectionState.Reconnecting);
+ _vm.StatusMessage.ShouldBe("Reconnecting...");
+ }
+
+ [Fact]
+ public async Task SelectedTreeNode_PropagatesToChildViewModels()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ var node = _vm.BrowseTree.RootNodes[0];
+ _vm.SelectedTreeNode = node;
+
+ _vm.ReadWrite.SelectedNodeId.ShouldBe(node.NodeId);
+ _vm.History.SelectedNodeId.ShouldBe(node.NodeId);
+ }
+
+ [Fact]
+ public async Task ConnectCommand_PropagatesIsConnectedToChildViewModels()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _vm.ReadWrite.IsConnected.ShouldBeTrue();
+ _vm.Subscriptions.IsConnected.ShouldBeTrue();
+ _vm.Alarms.IsConnected.ShouldBeTrue();
+ _vm.History.IsConnected.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task DisconnectCommand_PropagatesIsConnectedFalseToChildViewModels()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+ await _vm.DisconnectCommand.ExecuteAsync(null);
+
+ _vm.ReadWrite.IsConnected.ShouldBeFalse();
+ _vm.Subscriptions.IsConnected.ShouldBeFalse();
+ _vm.Alarms.IsConnected.ShouldBeFalse();
+ _vm.History.IsConnected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ConnectFailure_RevertsToDisconnected()
+ {
+ _service.ConnectException = new Exception("Connection refused");
+
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _vm.ConnectionState.ShouldBe(ConnectionState.Disconnected);
+ _vm.StatusMessage.ShouldContain("Connection refused");
+ }
+
+ [Fact]
+ public void PropertyChanged_FiredForConnectionState()
+ {
+ var changed = new List();
+ _vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);
+
+ _service.RaiseConnectionStateChanged(
+ new ConnectionStateChangedEventArgs(ConnectionState.Disconnected, ConnectionState.Connected, "opc.tcp://localhost:4840"));
+
+ changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
+ changed.ShouldContain(nameof(MainWindowViewModel.IsConnected));
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs
new file mode 100644
index 0000000..8e2dc89
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/ReadWriteViewModelTests.cs
@@ -0,0 +1,152 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
+
+public class ReadWriteViewModelTests
+{
+ private readonly FakeOpcUaClientService _service;
+ private readonly ReadWriteViewModel _vm;
+
+ public ReadWriteViewModelTests()
+ {
+ _service = new FakeOpcUaClientService
+ {
+ ReadResult = new DataValue(new Variant("TestValue"), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
+ };
+ var dispatcher = new SynchronousUiDispatcher();
+ _vm = new ReadWriteViewModel(_service, dispatcher);
+ }
+
+ [Fact]
+ public void ReadCommand_CannotExecute_WhenDisconnected()
+ {
+ _vm.IsConnected = false;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.ReadCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ReadCommand_CannotExecute_WhenNoNodeSelected()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = null;
+ _vm.ReadCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ReadCommand_CanExecute_WhenConnectedAndNodeSelected()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.ReadCommand.CanExecute(null).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ReadCommand_UpdatesValueAndStatus()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ // auto-read fires on selection change, so reset count
+ var countBefore = _service.ReadCallCount;
+
+ await _vm.ReadCommand.ExecuteAsync(null);
+
+ _vm.CurrentValue.ShouldBe("TestValue");
+ _vm.CurrentStatus.ShouldNotBeNull();
+ _vm.SourceTimestamp.ShouldNotBeNull();
+ _vm.ServerTimestamp.ShouldNotBeNull();
+ (_service.ReadCallCount - countBefore).ShouldBe(1);
+ }
+
+ [Fact]
+ public void AutoRead_OnSelectionChange_WhenConnected()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+
+ // The auto-read fires asynchronously; give it a moment
+ // In synchronous dispatcher it should fire immediately
+ _service.ReadCallCount.ShouldBeGreaterThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public void NullSelection_DoesNotCallService()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = null;
+
+ _service.ReadCallCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task WriteCommand_UpdatesWriteStatus()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.WriteValue = "NewValue";
+
+ // Reset read count from auto-read
+ var readCountBefore = _service.ReadCallCount;
+
+ await _vm.WriteCommand.ExecuteAsync(null);
+
+ _vm.WriteStatus.ShouldNotBeNull();
+ _service.WriteCallCount.ShouldBe(1);
+ _service.LastWriteValue.ShouldBe("NewValue");
+ }
+
+ [Fact]
+ public void WriteCommand_CannotExecute_WhenDisconnected()
+ {
+ _vm.IsConnected = false;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.WriteCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ReadCommand_Error_SetsErrorStatus()
+ {
+ _service.ReadException = new Exception("Read failed");
+ _vm.IsConnected = true;
+
+ // We need to set SelectedNodeId and manually trigger read
+ // because auto-read catches the exception too
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+
+ _vm.CurrentStatus.ShouldContain("Error");
+ }
+
+ [Fact]
+ public void Clear_ResetsAllProperties()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.WriteValue = "test";
+ _vm.WriteStatus = "Good";
+
+ _vm.Clear();
+
+ _vm.SelectedNodeId.ShouldBeNull();
+ _vm.CurrentValue.ShouldBeNull();
+ _vm.CurrentStatus.ShouldBeNull();
+ _vm.SourceTimestamp.ShouldBeNull();
+ _vm.ServerTimestamp.ShouldBeNull();
+ _vm.WriteValue.ShouldBeNull();
+ _vm.WriteStatus.ShouldBeNull();
+ }
+
+ [Fact]
+ public void IsNodeSelected_TracksSelectedNodeId()
+ {
+ _vm.IsNodeSelected.ShouldBeFalse();
+ _vm.SelectedNodeId = "ns=2;s=SomeNode";
+ _vm.IsNodeSelected.ShouldBeTrue();
+ _vm.SelectedNodeId = null;
+ _vm.IsNodeSelected.ShouldBeFalse();
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
new file mode 100644
index 0000000..60d612b
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
@@ -0,0 +1,135 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
+using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
+
+namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
+
+public class SubscriptionsViewModelTests
+{
+ private readonly FakeOpcUaClientService _service;
+ private readonly SubscriptionsViewModel _vm;
+
+ public SubscriptionsViewModelTests()
+ {
+ _service = new FakeOpcUaClientService();
+ var dispatcher = new SynchronousUiDispatcher();
+ _vm = new SubscriptionsViewModel(_service, dispatcher);
+ }
+
+ [Fact]
+ public void AddSubscriptionCommand_CannotExecute_WhenDisconnected()
+ {
+ _vm.IsConnected = false;
+ _vm.NewNodeIdText = "ns=2;s=SomeNode";
+ _vm.AddSubscriptionCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AddSubscriptionCommand_CannotExecute_WhenNoNodeId()
+ {
+ _vm.IsConnected = true;
+ _vm.NewNodeIdText = null;
+ _vm.AddSubscriptionCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task AddSubscriptionCommand_AddsItem()
+ {
+ _vm.IsConnected = true;
+ _vm.NewNodeIdText = "ns=2;s=SomeNode";
+ _vm.NewInterval = 500;
+
+ await _vm.AddSubscriptionCommand.ExecuteAsync(null);
+
+ _vm.ActiveSubscriptions.Count.ShouldBe(1);
+ _vm.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=SomeNode");
+ _vm.ActiveSubscriptions[0].IntervalMs.ShouldBe(500);
+ _vm.SubscriptionCount.ShouldBe(1);
+ _service.SubscribeCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task RemoveSubscriptionCommand_RemovesItem()
+ {
+ _vm.IsConnected = true;
+ _vm.NewNodeIdText = "ns=2;s=SomeNode";
+ await _vm.AddSubscriptionCommand.ExecuteAsync(null);
+
+ _vm.SelectedSubscription = _vm.ActiveSubscriptions[0];
+ await _vm.RemoveSubscriptionCommand.ExecuteAsync(null);
+
+ _vm.ActiveSubscriptions.ShouldBeEmpty();
+ _vm.SubscriptionCount.ShouldBe(0);
+ _service.UnsubscribeCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public void RemoveSubscriptionCommand_CannotExecute_WhenNoSelection()
+ {
+ _vm.IsConnected = true;
+ _vm.SelectedSubscription = null;
+ _vm.RemoveSubscriptionCommand.CanExecute(null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task DataChanged_UpdatesMatchingRow()
+ {
+ _vm.IsConnected = true;
+ _vm.NewNodeIdText = "ns=2;s=SomeNode";
+ await _vm.AddSubscriptionCommand.ExecuteAsync(null);
+
+ var dataValue = new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow);
+ _service.RaiseDataChanged(new DataChangedEventArgs("ns=2;s=SomeNode", dataValue));
+
+ _vm.ActiveSubscriptions[0].Value.ShouldBe("42");
+ _vm.ActiveSubscriptions[0].Status.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task DataChanged_DoesNotUpdateNonMatchingRow()
+ {
+ _vm.IsConnected = true;
+ _vm.NewNodeIdText = "ns=2;s=SomeNode";
+ await _vm.AddSubscriptionCommand.ExecuteAsync(null);
+
+ var dataValue = new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow);
+ _service.RaiseDataChanged(new DataChangedEventArgs("ns=2;s=OtherNode", dataValue));
+
+ _vm.ActiveSubscriptions[0].Value.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Clear_RemovesAllSubscriptions()
+ {
+ _vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=2;s=X", 1000));
+ _vm.SubscriptionCount = 1;
+
+ _vm.Clear();
+
+ _vm.ActiveSubscriptions.ShouldBeEmpty();
+ _vm.SubscriptionCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Teardown_UnhooksEventHandler()
+ {
+ _vm.Teardown();
+
+ // After teardown, raising event should not update anything
+ _vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=2;s=X", 1000));
+ var dataValue = new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow);
+ _service.RaiseDataChanged(new DataChangedEventArgs("ns=2;s=X", dataValue));
+
+ _vm.ActiveSubscriptions[0].Value.ShouldBeNull();
+ }
+
+ [Fact]
+ public void DefaultInterval_Is1000()
+ {
+ _vm.NewInterval.ShouldBe(1000);
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.csproj b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.csproj
new file mode 100644
index 0000000..a5d10cf
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.LmxOpcUa.Client.UI.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+