|
|
|
|
@@ -0,0 +1,322 @@
|
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
|
using Opc.Ua;
|
|
|
|
|
using Opc.Ua.Client;
|
|
|
|
|
using Opc.Ua.Configuration;
|
|
|
|
|
using Shouldly;
|
|
|
|
|
using Xunit;
|
|
|
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
|
|
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
|
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
|
|
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
|
|
|
|
|
/// dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
|
|
|
|
|
/// opens a client session, raises a driver-side transition, and asserts it propagates
|
|
|
|
|
/// through <c>GenericDriverNodeManager</c>'s alarm forwarder into
|
|
|
|
|
/// <c>DriverNodeManager.ConditionSink</c>, updates the server-side
|
|
|
|
|
/// <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
|
|
|
|
|
/// flows out to an OPC UA subscription on the Server object's EventNotifier.
|
|
|
|
|
///
|
|
|
|
|
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
|
|
|
|
|
/// <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
|
|
|
|
|
/// integration gap for optional driver capabilities (plan decision #62).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Trait("Category", "Integration")]
|
|
|
|
|
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
|
|
|
|
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
|
|
|
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");
|
|
|
|
|
|
|
|
|
|
private DriverHost _driverHost = null!;
|
|
|
|
|
private OpcUaApplicationHost _server = null!;
|
|
|
|
|
private AlarmDriver _driver = null!;
|
|
|
|
|
|
|
|
|
|
public async ValueTask InitializeAsync()
|
|
|
|
|
{
|
|
|
|
|
_driverHost = new DriverHost();
|
|
|
|
|
_driver = new AlarmDriver();
|
|
|
|
|
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
var options = new OpcUaServerOptions
|
|
|
|
|
{
|
|
|
|
|
EndpointUrl = _endpoint,
|
|
|
|
|
ApplicationName = "OtOpcUaAlarmTest",
|
|
|
|
|
ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
|
|
|
|
|
PkiStoreRoot = _pkiRoot,
|
|
|
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
|
|
|
HealthEndpointsEnabled = false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
|
|
|
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
|
await _server.StartAsync(CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async ValueTask DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
await _server.DisposeAsync();
|
|
|
|
|
await _driverHost.DisposeAsync();
|
|
|
|
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
|
|
|
|
|
{
|
|
|
|
|
using var session = await OpenSessionAsync();
|
|
|
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
|
|
|
|
|
|
|
|
|
_driver.RaiseAlarm(new AlarmEventArgs(
|
|
|
|
|
SubscriptionHandle: new FakeHandle("sub"),
|
|
|
|
|
SourceNodeId: "Tank.HiHi",
|
|
|
|
|
ConditionId: "cond-1",
|
|
|
|
|
AlarmType: "Active",
|
|
|
|
|
Message: "Level exceeded upper-upper",
|
|
|
|
|
Severity: AlarmSeverity.High,
|
|
|
|
|
SourceTimestampUtc: DateTime.UtcNow));
|
|
|
|
|
|
|
|
|
|
// The alarm-condition node's identifier is the driver full-reference + ".Condition"
|
|
|
|
|
// (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
|
|
|
|
|
// are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
|
|
|
|
|
// so by the time RaiseAlarm returns the node state has been flushed.
|
|
|
|
|
var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);
|
|
|
|
|
|
|
|
|
|
// Browse the condition node for the well-known Part-9 child variables. The stack
|
|
|
|
|
// materializes Severity / Message / ActiveState / AckedState as children below the
|
|
|
|
|
// AlarmConditionState; their NodeIds are allocated by the stack so we discover them
|
|
|
|
|
// by BrowseName rather than guessing.
|
|
|
|
|
var browseDescriptions = new BrowseDescriptionCollection
|
|
|
|
|
{
|
|
|
|
|
new()
|
|
|
|
|
{
|
|
|
|
|
NodeId = conditionNodeId,
|
|
|
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
|
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
|
|
|
IncludeSubtypes = true,
|
|
|
|
|
NodeClassMask = 0,
|
|
|
|
|
ResultMask = (uint)BrowseResultMask.All,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
|
|
|
|
|
var children = browseResults[0].References
|
|
|
|
|
.ToDictionary(r => r.BrowseName.Name,
|
|
|
|
|
r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
|
|
|
|
|
StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
children.ShouldContainKey("Severity");
|
|
|
|
|
children.ShouldContainKey("Message");
|
|
|
|
|
children.ShouldContainKey("ActiveState");
|
|
|
|
|
|
|
|
|
|
// Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
|
|
|
|
|
// the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
|
|
|
|
|
// landed the new values in addressable child nodes. DriverNodeManager's
|
|
|
|
|
// AssignSymbolicDescendantIds keeps each child reachable under the node manager's
|
|
|
|
|
// namespace so Read resolves against the predefined-node dictionary.
|
|
|
|
|
var severity = session.ReadValue(children["Severity"]);
|
|
|
|
|
var message = session.ReadValue(children["Message"]);
|
|
|
|
|
severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity)
|
|
|
|
|
((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");
|
|
|
|
|
|
|
|
|
|
// ActiveState exposes its boolean Id as a HasProperty child.
|
|
|
|
|
var activeBrowse = new BrowseDescriptionCollection
|
|
|
|
|
{
|
|
|
|
|
new()
|
|
|
|
|
{
|
|
|
|
|
NodeId = children["ActiveState"],
|
|
|
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
|
|
|
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
|
|
|
|
IncludeSubtypes = true,
|
|
|
|
|
ResultMask = (uint)BrowseResultMask.All,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
|
|
|
|
|
var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
|
|
|
|
|
var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
|
|
|
|
|
activeId.Value.ShouldBe(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
|
|
|
|
|
{
|
|
|
|
|
// AddRootNotifier registers the AlarmConditionState as a Server-object notifier
|
|
|
|
|
// source, so a subscription with an EventFilter on Server receives the
|
|
|
|
|
// ReportEvent calls ConditionSink emits per-transition.
|
|
|
|
|
using var session = await OpenSessionAsync();
|
|
|
|
|
|
|
|
|
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
|
|
|
|
session.AddSubscription(subscription);
|
|
|
|
|
await subscription.CreateAsync();
|
|
|
|
|
|
|
|
|
|
var received = new List<EventFieldList>();
|
|
|
|
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
|
|
|
|
|
var filter = new EventFilter();
|
|
|
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
|
|
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
|
|
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
|
|
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
|
|
|
|
filter.WhereClause = new ContentFilter();
|
|
|
|
|
filter.WhereClause.Push(FilterOperator.OfType,
|
|
|
|
|
new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });
|
|
|
|
|
|
|
|
|
|
var item = new MonitoredItem(subscription.DefaultItem)
|
|
|
|
|
{
|
|
|
|
|
StartNodeId = ObjectIds.Server,
|
|
|
|
|
AttributeId = Attributes.EventNotifier,
|
|
|
|
|
NodeClass = NodeClass.Object,
|
|
|
|
|
SamplingInterval = 0,
|
|
|
|
|
QueueSize = 100,
|
|
|
|
|
Filter = filter,
|
|
|
|
|
};
|
|
|
|
|
item.Notification += (_, e) =>
|
|
|
|
|
{
|
|
|
|
|
if (e.NotificationValue is EventFieldList fields)
|
|
|
|
|
{
|
|
|
|
|
lock (received) { received.Add(fields); gate.TrySetResult(); }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
subscription.AddItem(item);
|
|
|
|
|
await subscription.ApplyChangesAsync();
|
|
|
|
|
|
|
|
|
|
// Give the publish loop a tick to establish before firing.
|
|
|
|
|
await Task.Delay(200);
|
|
|
|
|
|
|
|
|
|
_driver.RaiseAlarm(new AlarmEventArgs(
|
|
|
|
|
new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
|
|
|
|
|
"High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));
|
|
|
|
|
|
|
|
|
|
var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
|
|
|
|
|
delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");
|
|
|
|
|
|
|
|
|
|
EventFieldList first;
|
|
|
|
|
lock (received) first = received[0];
|
|
|
|
|
// Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
|
|
|
|
|
((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
|
|
|
|
|
first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
|
|
|
|
|
{
|
|
|
|
|
// Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
|
|
|
|
|
// MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
|
|
|
|
|
// a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
|
|
|
|
|
// under the driver full-reference. Browse should show both condition nodes with
|
|
|
|
|
// distinct NodeIds using the FullReference + ".Condition" convention.
|
|
|
|
|
using var session = await OpenSessionAsync();
|
|
|
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
|
|
|
|
|
|
|
|
|
_driver.RaiseAlarm(new AlarmEventArgs(
|
|
|
|
|
new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
|
|
|
|
|
DateTime.UtcNow));
|
|
|
|
|
|
|
|
|
|
var attrs = new ReadValueIdCollection
|
|
|
|
|
{
|
|
|
|
|
new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
|
|
|
|
new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
|
|
|
|
};
|
|
|
|
|
session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
|
|
|
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
|
results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
|
((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
|
|
|
|
|
((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<ISession> OpenSessionAsync()
|
|
|
|
|
{
|
|
|
|
|
var cfg = new ApplicationConfiguration
|
|
|
|
|
{
|
|
|
|
|
ApplicationName = "OtOpcUaAlarmTestClient",
|
|
|
|
|
ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
|
|
|
|
|
ApplicationType = ApplicationType.Client,
|
|
|
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
|
|
|
{
|
|
|
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
|
|
|
{
|
|
|
|
|
StoreType = CertificateStoreType.Directory,
|
|
|
|
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
|
|
|
|
SubjectName = "CN=OtOpcUaAlarmTestClient",
|
|
|
|
|
},
|
|
|
|
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
|
|
|
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
|
|
|
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
|
|
|
|
AutoAcceptUntrustedCertificates = true,
|
|
|
|
|
AddAppCertToTrustedStore = true,
|
|
|
|
|
},
|
|
|
|
|
TransportConfigurations = new TransportConfigurationCollection(),
|
|
|
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
|
|
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
|
|
|
|
};
|
|
|
|
|
await cfg.Validate(ApplicationType.Client);
|
|
|
|
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
|
|
|
|
|
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
|
|
|
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
|
|
|
|
|
|
|
|
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
|
|
|
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
|
|
|
|
|
|
|
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
|
|
|
|
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
|
|
|
|
|
/// bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
|
|
|
|
|
/// fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
|
|
|
|
|
{
|
|
|
|
|
public string DriverInstanceId => "alarm-driver";
|
|
|
|
|
public string DriverType => "AlarmStub";
|
|
|
|
|
|
|
|
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
|
|
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
|
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
|
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
|
|
|
public long GetMemoryFootprint() => 0;
|
|
|
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var tank = builder.Folder("Tank", "Tank");
|
|
|
|
|
var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
|
|
|
|
"Tank.HiHi", DriverDataType.Boolean, false, null,
|
|
|
|
|
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
|
|
|
|
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
|
|
|
|
|
"Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
|
|
|
|
|
|
|
|
|
var heater = builder.Folder("Heater", "Heater");
|
|
|
|
|
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
|
|
|
|
"Heater.OverTemp", DriverDataType.Boolean, false, null,
|
|
|
|
|
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
|
|
|
|
ot.MarkAsAlarmCondition(new AlarmConditionInfo(
|
|
|
|
|
"Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
|
|
|
|
|
|
|
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
|
|
|
IReadOnlyList<string> _, CancellationToken __)
|
|
|
|
|
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
|
|
|
|
|
|
|
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
|
|
|
|
|
=> Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
public Task AcknowledgeAsync(
|
|
|
|
|
IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
|
|
|
|
|
=> Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
|
|
|
|
{
|
|
|
|
|
public string DiagnosticId { get; } = diagnosticId;
|
|
|
|
|
}
|
|
|
|
|
}
|