diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/DisableCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/DisableCommand.cs new file mode 100644 index 00000000..68641697 --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/DisableCommand.cs @@ -0,0 +1,55 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; + +[Command("disable", Description = "Disable an alarm condition (OPC UA Part 9 ConditionType Disable)")] +public class DisableCommand : CommandBase +{ + /// + /// Creates the disable command used to disable an OPC UA alarm condition from the terminal. + /// + /// The factory that creates the shared client service for the command run. + public DisableCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + + /// + /// Gets the condition node ID of the alarm to disable. + /// + [CommandOption("node", 'n', Description = "Condition node ID of the alarm to disable", IsRequired = true)] + public string NodeId { get; init; } = default!; + + /// + /// Connects to the server and disables the specified alarm condition. + /// + /// The CLI console used for output and cancellation handling. + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + + IOpcUaClientService? service = null; + try + { + var ct = console.RegisterCancellationHandler(); + (service, _) = await CreateServiceAndConnectAsync(ct); + + var statusCode = await service.DisableAsync(NodeId, ct); + + if (StatusCode.IsGood(statusCode)) + await console.Output.WriteLineAsync($"Disable successful: {NodeId}"); + else + await console.Output.WriteLineAsync($"Disable failed: {statusCode}"); + } + finally + { + if (service != null) + { + await service.DisconnectAsync(); + service.Dispose(); + } + } + } +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/EnableCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/EnableCommand.cs new file mode 100644 index 00000000..2ecca347 --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/EnableCommand.cs @@ -0,0 +1,55 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; + +[Command("enable", Description = "Enable an alarm condition (OPC UA Part 9 ConditionType Enable)")] +public class EnableCommand : CommandBase +{ + /// + /// Creates the enable command used to enable an OPC UA alarm condition from the terminal. + /// + /// The factory that creates the shared client service for the command run. + public EnableCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + + /// + /// Gets the condition node ID of the alarm to enable. + /// + [CommandOption("node", 'n', Description = "Condition node ID of the alarm to enable", IsRequired = true)] + public string NodeId { get; init; } = default!; + + /// + /// Connects to the server and enables the specified alarm condition. + /// + /// The CLI console used for output and cancellation handling. + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + + IOpcUaClientService? service = null; + try + { + var ct = console.RegisterCancellationHandler(); + (service, _) = await CreateServiceAndConnectAsync(ct); + + var statusCode = await service.EnableAsync(NodeId, ct); + + if (StatusCode.IsGood(statusCode)) + await console.Output.WriteLineAsync($"Enable successful: {NodeId}"); + else + await console.Output.WriteLineAsync($"Enable failed: {statusCode}"); + } + finally + { + if (service != null) + { + await service.DisconnectAsync(); + service.Dispose(); + } + } + } +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs index 2eafcbd4..42726851 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs @@ -135,6 +135,32 @@ public interface IOpcUaClientService : IDisposable /// Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, CancellationToken ct = default); + /// + /// Enables an alarm condition by invoking the OPC UA Part 9 ConditionType Enable method + /// (the H4 client-driven path). The method takes no input arguments. + /// + /// The condition node associated with the alarm being enabled. + /// The cancellation token that aborts the enable request. + /// + /// on success, or the server's bad + /// (from the underlying ) when the enable call + /// returns a bad result. Other transport-level failures still surface as exceptions. + /// + Task EnableAsync(string conditionNodeId, CancellationToken ct = default); + + /// + /// Disables an alarm condition by invoking the OPC UA Part 9 ConditionType Disable method + /// (the H4 client-driven path). The method takes no input arguments. + /// + /// The condition node associated with the alarm being disabled. + /// The cancellation token that aborts the disable request. + /// + /// on success, or the server's bad + /// (from the underlying ) when the disable call + /// returns a bad result. Other transport-level failures still surface as exceptions. + /// + Task DisableAsync(string conditionNodeId, CancellationToken ct = default); + /// /// Reads raw historical samples for a historized node. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 255ce078..b54f1f5b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -461,6 +461,50 @@ public sealed class OpcUaClientService : IOpcUaClientService return StatusCodes.Good; } + /// + public Task EnableAsync(string conditionNodeId, CancellationToken ct = default) => + CallEnableDisableAsync(conditionNodeId, enabling: true, ct); + + /// + public Task DisableAsync(string conditionNodeId, CancellationToken ct = default) => + CallEnableDisableAsync(conditionNodeId, enabling: false, ct); + + /// + /// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType + /// Enable/Disable method (no input arguments) on the condition object node, mirroring + /// the convention: the methods live on the .Condition + /// child node, and a bad call result surfaces as the returned rather than + /// escaping as an uncaught . + /// + /// The condition node associated with the alarm. + /// true to invoke Enable; false to invoke Disable. + /// The cancellation token that aborts the request. + private async Task CallEnableDisableAsync(string conditionNodeId, bool enabling, CancellationToken ct) + { + ThrowIfDisposed(); + ThrowIfNotConnected(); + + // Enable/Disable live on the same .Condition child node as Acknowledge/Confirm. + var conditionObjId = conditionNodeId.EndsWith(".Condition") + ? NodeId.Parse(conditionNodeId) + : NodeId.Parse(conditionNodeId + ".Condition"); + var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable; + + try + { + await _session!.CallMethodAsync(conditionObjId, methodId, [], ct); + } + catch (ServiceResultException ex) + { + Logger.Warning(ex, "Failed to {Operation} alarm on {ConditionId} (status {Status})", + enabling ? "enable" : "disable", conditionNodeId, ex.StatusCode); + return ex.StatusCode; + } + + Logger.Debug("{Operation} alarm on {ConditionId}", enabling ? "Enabled" : "Disabled", conditionNodeId); + return StatusCodes.Good; + } + /// public async Task> HistoryReadRawAsync( NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/DisableCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/DisableCommandTests.cs new file mode 100644 index 00000000..1c1718c8 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/DisableCommandTests.cs @@ -0,0 +1,75 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests; + +public class DisableCommandTests +{ + /// Verifies that a successful disable prints a success message and targets the node. + [Fact] + public async Task Execute_Success_PrintsSuccessMessage() + { + var fakeService = new FakeOpcUaClientService + { + DisableResult = StatusCodes.Good + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new DisableCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Disable successful"); + fakeService.DisableCalls.Count.ShouldBe(1); + fakeService.DisableCalls[0].ShouldBe("ns=2;s=MyAlarm"); + } + + /// Verifies that a bad StatusCode from the service prints a failure message. + [Fact] + public async Task Execute_BadStatus_PrintsFailureMessage() + { + var fakeService = new FakeOpcUaClientService + { + DisableResult = StatusCodes.BadConditionAlreadyDisabled + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new DisableCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Disable failed"); + } + + /// Verifies that the command disconnects and disposes in the finally block. + [Fact] + public async Task Execute_DisconnectsInFinally() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new DisableCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.DisconnectCalled.ShouldBeTrue(); + fakeService.DisposeCalled.ShouldBeTrue(); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/EnableCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/EnableCommandTests.cs new file mode 100644 index 00000000..936b13ab --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/EnableCommandTests.cs @@ -0,0 +1,75 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests; + +public class EnableCommandTests +{ + /// Verifies that a successful enable prints a success message and targets the node. + [Fact] + public async Task Execute_Success_PrintsSuccessMessage() + { + var fakeService = new FakeOpcUaClientService + { + EnableResult = StatusCodes.Good + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new EnableCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Enable successful"); + fakeService.EnableCalls.Count.ShouldBe(1); + fakeService.EnableCalls[0].ShouldBe("ns=2;s=MyAlarm"); + } + + /// Verifies that a bad StatusCode from the service prints a failure message. + [Fact] + public async Task Execute_BadStatus_PrintsFailureMessage() + { + var fakeService = new FakeOpcUaClientService + { + EnableResult = StatusCodes.BadConditionAlreadyEnabled + }; + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new EnableCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Enable failed"); + } + + /// Verifies that the command disconnects and disposes in the finally block. + [Fact] + public async Task Execute_DisconnectsInFinally() + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new EnableCommand(factory) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=MyAlarm" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.DisconnectCalled.ShouldBeTrue(); + fakeService.DisposeCalled.ShouldBeTrue(); + } +} diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs index 8b213c4c..316beb7c 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs @@ -262,6 +262,32 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService return Task.FromResult(ShelveAlarmResult); } + /// Gets the list of condition node IDs passed to EnableAsync calls. + public List EnableCalls { get; } = []; + + /// Gets or sets the status code returned by EnableAsync. + public StatusCode EnableResult { get; set; } = StatusCodes.Good; + + /// + public Task EnableAsync(string conditionNodeId, CancellationToken ct = default) + { + EnableCalls.Add(conditionNodeId); + return Task.FromResult(EnableResult); + } + + /// Gets the list of condition node IDs passed to DisableAsync calls. + public List DisableCalls { get; } = []; + + /// Gets or sets the status code returned by DisableAsync. + public StatusCode DisableResult { get; set; } = StatusCodes.Good; + + /// + public Task DisableAsync(string conditionNodeId, CancellationToken ct = default) + { + DisableCalls.Add(conditionNodeId); + return Task.FromResult(DisableResult); + } + /// public Task> HistoryReadRawAsync( NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs index 5abf1068..a73a9f65 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs @@ -208,11 +208,25 @@ internal sealed class FakeSessionAdapter : ISessionAdapter /// public List CallMethodInputArgs { get; } = []; + /// + /// Records the object node id passed to each invocation, + /// so tests can assert which condition object a method was invoked on. + /// + public List CallMethodObjectIds { get; } = []; + + /// + /// Records the method node id passed to each invocation, + /// so tests can assert the correct Part 9 method (e.g. Enable/Disable) was invoked. + /// + public List CallMethodMethodIds { get; } = []; + /// public Task?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default) { CallMethodCount++; + CallMethodObjectIds.Add(objectId); + CallMethodMethodIds.Add(methodId); CallMethodInputArgs.Add(inputArguments); if (CallMethodException != null) throw CallMethodException; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs index 8c0fc1f8..820d30b4 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs @@ -1174,6 +1174,88 @@ public class OpcUaClientServiceTests : IDisposable result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved); } + // --- Enable / Disable tests (H4 client path) --- + + /// + /// Verifies that EnableAsync invokes the ConditionType_Enable method with no input + /// arguments and returns on success. + /// + [Fact] + public async Task EnableAsync_OnSuccess_CallsEnableMethodWithNoArgs() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.EnableAsync("ns=2;s=Cond"); + + result.ShouldBe(StatusCodes.Good); + session.CallMethodCount.ShouldBe(1); + session.CallMethodMethodIds[0].ShouldBe(MethodIds.ConditionType_Enable); + session.CallMethodInputArgs[0].ShouldBeEmpty(); + } + + /// + /// Verifies that DisableAsync invokes the ConditionType_Disable method with no input + /// arguments and returns on success. + /// + [Fact] + public async Task DisableAsync_OnSuccess_CallsDisableMethodWithNoArgs() + { + var session = new FakeSessionAdapter(); + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.DisableAsync("ns=2;s=Cond"); + + result.ShouldBe(StatusCodes.Good); + session.CallMethodCount.ShouldBe(1); + session.CallMethodMethodIds[0].ShouldBe(MethodIds.ConditionType_Disable); + session.CallMethodInputArgs[0].ShouldBeEmpty(); + } + + /// + /// Verifies that a from the session is captured and + /// returned as a bad rather than propagating to the caller. + /// + [Fact] + public async Task EnableAsync_OnServiceResultException_ReturnsBadStatusCode() + { + var session = new FakeSessionAdapter + { + CallMethodException = new ServiceResultException( + StatusCodes.BadConditionAlreadyEnabled, "already enabled") + }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.EnableAsync("ns=2;s=Cond"); + + StatusCode.IsBad(result).ShouldBeTrue(); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled); + } + + /// + /// Verifies that a from the session is captured and + /// returned as a bad rather than propagating to the caller. + /// + [Fact] + public async Task DisableAsync_OnServiceResultException_ReturnsBadStatusCode() + { + var session = new FakeSessionAdapter + { + CallMethodException = new ServiceResultException( + StatusCodes.BadConditionAlreadyDisabled, "already disabled") + }; + _sessionFactory.EnqueueSession(session); + await _service.ConnectAsync(ValidSettings()); + + var result = await _service.DisableAsync("ns=2;s=Cond"); + + StatusCode.IsBad(result).ShouldBeTrue(); + result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled); + } + // --- Alarm fallback path (Client.Shared-011) --- /// diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs index d1fef1a9..210abbb8 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs @@ -301,6 +301,42 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService return Task.FromResult(ShelveResult); } + /// Gets or sets the status code returned by enable operations in UI tests. + public StatusCode EnableResult { get; set; } = StatusCodes.Good; + /// Gets or sets the exception thrown to simulate alarm enable failures in the UI. + public Exception? EnableException { get; set; } + /// Gets the number of times EnableAsync has been called. + public int EnableCallCount { get; private set; } + /// Gets the conditionNodeId of the last EnableAsync call. + public string? LastEnableCall { get; private set; } + + /// + public Task EnableAsync(string conditionNodeId, CancellationToken ct = default) + { + EnableCallCount++; + LastEnableCall = conditionNodeId; + if (EnableException != null) throw EnableException; + return Task.FromResult(EnableResult); + } + + /// Gets or sets the status code returned by disable operations in UI tests. + public StatusCode DisableResult { get; set; } = StatusCodes.Good; + /// Gets or sets the exception thrown to simulate alarm disable failures in the UI. + public Exception? DisableException { get; set; } + /// Gets the number of times DisableAsync has been called. + public int DisableCallCount { get; private set; } + /// Gets the conditionNodeId of the last DisableAsync call. + public string? LastDisableCall { get; private set; } + + /// + public Task DisableAsync(string conditionNodeId, CancellationToken ct = default) + { + DisableCallCount++; + LastDisableCall = conditionNodeId; + if (DisableException != null) throw DisableException; + return Task.FromResult(DisableResult); + } + /// public Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)