diff --git a/docs/NodeManagers.md b/docs/NodeManagers.md index 61e4c9315f..aeeff44871 100644 --- a/docs/NodeManagers.md +++ b/docs/NodeManagers.md @@ -2320,6 +2320,19 @@ builder.Node("Events") }); ``` +For any other condition type, such as an alarm type of a companion +specification or a server-specific subtype, `.CreateAlarm` takes a +factory for the uninitialized state. The alarm is attached, enabled and +released on teardown exactly like the alarms of the dedicated helpers: + +```csharp +builder.Node("Pumps/Pump #1/Events") + .CreateAlarm( + new QualifiedName("ValveDiscrepancy", pumpsNs), + parent => new DiscrepancyAlarmState(parent)) + .OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good); +``` + #### Boolean supervision → alarm activation (NAMUR pattern) `IVariableBuilder.OnRisingEdge` / `.OnFallingEdge` register diff --git a/docs/Profiles.md b/docs/Profiles.md index 96d72157d9..fa93e39110 100644 --- a/docs/Profiles.md +++ b/docs/Profiles.md @@ -560,6 +560,23 @@ reference-server and CTT configs in line with the facets the stack actually implements is tracked in [#3875](https://github.com/OPCFoundation/UA-.NETStandard/issues/3875). +### Contributed profiles and conformance units + +Node managers that implement `IConformanceContributor`, such as the +companion node managers, contribute the facets and conformance units they +actually wire. At startup the server registers them with the +`ConformanceUnitsManager` and publishes the aggregate to +`Server/ServerCapabilities/ConformanceUnits` and `ServerProfileArray`. + +The manager keeps the registered contributors and reads them again on every +`PublishAsync`, because the ServerCapabilities describe the current +configuration of the server (OPC 10000-5 §6.3.2). A contributor whose +support changes at runtime calls `PublishAsync` afterwards (the manager is +`ServerInternalData.ConformanceUnitsManager`); a unit or profile +that no contributor reports any more is withdrawn. `Unregister` removes a +contributor. The profiles from the `ServerProfileArray` configuration are +always kept. + ### Security policy configuration Security policies are configured in the `SecurityPolicies` section: diff --git a/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs b/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs index d7683bd6d1..98ef52820b 100644 --- a/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs +++ b/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs @@ -1760,11 +1760,6 @@ namespace Opc.Ua.Server conformanceUnitsNode.ClearChangeMasks(SystemContext, false); } - if (serverProfiles.Count == 0) - { - return; - } - BaseVariableState? profileArrayNode = FindPredefinedNode( VariableIds.Server_ServerCapabilities_ServerProfileArray); @@ -1773,19 +1768,26 @@ namespace Opc.Ua.Server return; } - // Preserve profiles already declared (e.g. from configuration) and - // append the contributed ones that are not already present. - var merged = new List(); - if (profileArrayNode.Value.TryGetValue(out ArrayOf existing)) + // The profiles the server declares itself (from configuration) + // are taken from the first publish on and always kept. The + // contributed ones are replaced on every publish, so a profile + // no contributor reports any more disappears. + if (m_declaredServerProfiles == null) { - foreach (string profile in existing) + m_declaredServerProfiles = []; + if (profileArrayNode.Value.TryGetValue(out ArrayOf existing)) { - if (!string.IsNullOrEmpty(profile)) + foreach (string profile in existing) { - merged.Add(profile); + if (!string.IsNullOrEmpty(profile) && !m_declaredServerProfiles.Contains(profile)) + { + m_declaredServerProfiles.Add(profile); + } } } } + + var merged = new List(m_declaredServerProfiles); foreach (string profile in serverProfiles) { if (!string.IsNullOrEmpty(profile) && !merged.Contains(profile)) @@ -2745,6 +2747,7 @@ namespace Opc.Ua.Server private readonly ConcurrentDictionary m_sampledItems; private readonly double m_minimumSamplingInterval; private HistoryServerCapabilitiesState? m_historyCapabilities; + private List? m_declaredServerProfiles; /// /// Aggregates the per-node capabilities advertised by every diff --git a/src/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs b/src/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs index c73b90137d..241206ddb9 100644 --- a/src/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs +++ b/src/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs @@ -60,8 +60,9 @@ namespace Opc.Ua.Server /// Publishes the supported conformance units to /// Server/ServerCapabilities/ConformanceUnits and merges the /// enabled server profiles into Server/ServerCapabilities/ServerProfileArray - /// (per OPC UA Part 7). Profiles already present (e.g. from configuration) - /// are preserved. + /// (per OPC UA Part 7). Profiles present before the first call (e.g. from + /// configuration) are preserved; the merged profiles of an earlier call + /// are replaced, so a profile left out is withdrawn. /// ValueTask PublishConformanceUnitsAsync( ArrayOf conformanceUnits, diff --git a/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs index 2ba069e36f..86b7e896ce 100644 --- a/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs @@ -129,7 +129,8 @@ namespace Opc.Ua.Server.Fluent /// Strongly-typed fluent builder for an alarm/condition state /// instance. Returned by the CreateLimitAlarm / /// CreateExclusiveLimitAlarm / CreateOffNormalAlarm - /// helpers on . + /// helpers on , and by CreateAlarm for + /// any other condition type. /// /// /// @@ -251,6 +252,46 @@ namespace Opc.Ua.Server.Fluent return new AlarmBuilder(parent, alarm); } + /// + /// Creates an alarm of any condition type under the resolved + /// parent, for the types the dedicated helpers do not cover - a + /// companion specification's alarm or condition type, or a server + /// specific subtype. The alarm is attached and registered exactly + /// as the dedicated helpers attach theirs: it is enabled, the + /// parent becomes its SourceNode, SourceName and + /// event notifier, and a fluent node manager releases all of that + /// again on teardown. + /// + /// The condition state the factory creates. + /// The Object the alarm is created on. + /// The browse name of the alarm. + /// + /// Creates the uninitialized alarm for the parent it is handed, for + /// example parent => new OffNormalAlarmState(parent). The + /// builder assigns the identity and initializes the alarm. + /// + /// A builder for further configuration of the alarm. + /// + /// , or + /// is null. + /// + /// + /// when the parent is not an + /// Object. + /// + /// + /// returned null. + /// + public static IAlarmBuilder CreateAlarm( + this INodeBuilder parent, + QualifiedName browseName, + Func factory) + where TState : ConditionState + { + TState alarm = AttachAlarm(parent, browseName, factory); + return new AlarmBuilder(parent, alarm); + } + /// /// Escape hatch: directly mutate the underlying alarm state. /// Use for properties not covered by the narrow MVP surface @@ -298,6 +339,7 @@ namespace Opc.Ua.Server.Fluent /// The concrete alarm state created by the factory. /// is null. /// + /// The factory returned null. private static TState AttachAlarm( INodeBuilder parent, QualifiedName browseName, @@ -325,7 +367,8 @@ namespace Opc.Ua.Server.Fluent parent.Node.NodeClass); } string symbolicName = browseName.Name ?? string.Empty; - TState alarm = factory(parent.Node); + TState alarm = factory(parent.Node) + ?? throw new InvalidOperationException("The alarm factory returned no alarm."); alarm.SymbolicName = symbolicName; alarm.BrowseName = browseName; alarm.DisplayName = new LocalizedText(symbolicName); diff --git a/src/Opc.Ua.Server/Historian/HistorianConformanceContributor.cs b/src/Opc.Ua.Server/Historian/HistorianConformanceContributor.cs index 0f5560dedc..87d324a96f 100644 --- a/src/Opc.Ua.Server/Historian/HistorianConformanceContributor.cs +++ b/src/Opc.Ua.Server/Historian/HistorianConformanceContributor.cs @@ -47,11 +47,12 @@ namespace Opc.Ua.Server.Historian /// /// and are /// a snapshot computed by the last call - /// (empty until the first call). - /// reads a contributor's properties once, so callers must - /// before registering, and again — followed - /// by re-registering — whenever a provider is added, removed, or its - /// capabilities change. + /// (empty until the first call). + /// reads a registered contributor's properties on every + /// , so callers must + /// before the server publishes, and again — + /// followed by — + /// whenever a provider is added, removed, or its capabilities change. /// /// /// Only Server-side profiles are considered; this contributor diff --git a/src/Opc.Ua.Server/Server/ConformanceUnitsManager.cs b/src/Opc.Ua.Server/Server/ConformanceUnitsManager.cs index c55eac819e..050ed63c72 100644 --- a/src/Opc.Ua.Server/Server/ConformanceUnitsManager.cs +++ b/src/Opc.Ua.Server/Server/ConformanceUnitsManager.cs @@ -40,6 +40,7 @@ namespace Opc.Ua.Server /// and Server/ServerCapabilities/ServerProfileArray (OPC UA Part 7). /// /// + /// /// The published set is derived from the contributions of every registered /// node manager (or other feature) implementing /// rather than a fixed hard-coded list, @@ -48,6 +49,14 @@ namespace Opc.Ua.Server /// present. Registrations are aggregated and de-duplicated, then written to /// the address space through the , /// mirroring how delegates to it. + /// + /// + /// The manager keeps the registered contributors and reads them again on + /// every , because the capabilities describe the + /// current configuration of the server (OPC 10000-5 §6.3.2): a unit or + /// profile a contributor no longer reports disappears with the next + /// publish. The profiles the server declares in its configuration stay. + /// /// public class ConformanceUnitsManager : IDisposable { @@ -58,7 +67,6 @@ namespace Opc.Ua.Server { m_server = server ?? throw new ArgumentNullException(nameof(server)); m_conformanceUnits = []; - m_serverProfiles = new HashSet(StringComparer.Ordinal); } /// @@ -75,7 +83,11 @@ namespace Opc.Ua.Server /// protected virtual void Dispose(bool disposing) { - // No unmanaged resources held. + if (disposing && !m_disposed) + { + m_disposed = true; + m_publishLock.Dispose(); + } } /// @@ -85,7 +97,8 @@ namespace Opc.Ua.Server /// The browse name of the conformance unit. /// /// - /// True if the conformance unit is currently advertised. + /// True if the conformance unit is supported as of the last + /// registration or publish. /// public bool IsSupported(QualifiedName conformanceUnit) { @@ -101,9 +114,14 @@ namespace Opc.Ua.Server } /// - /// Registers the conformance units and server profiles a contributor - /// enables. + /// Registers a contributor whose conformance units and server profiles + /// the server advertises. /// + /// + /// Registering a contributor again has no effect and does not read it + /// again; every contributor is read again on each + /// . + /// /// /// The contributor whose supported units and profiles are added. /// @@ -117,50 +135,135 @@ namespace Opc.Ua.Server lock (m_lock) { - foreach (QualifiedName unit in contributor.ConformanceUnits) + if (m_contributors.Contains(contributor)) { - if (!unit.IsNull) - { - m_conformanceUnits.Add(unit); - } - } - foreach (string profile in contributor.ServerProfiles) - { - if (!string.IsNullOrEmpty(profile)) - { - m_serverProfiles.Add(profile); - } + return; } + m_contributors.Add(contributor); + m_registrationVersion++; } + Aggregate(); } /// - /// Publishes the aggregated conformance units and server profiles to the - /// address space through the diagnostics node manager. + /// Removes a contributor; its units and profiles disappear with the + /// next unless another contributor reports + /// them. + /// + /// The contributor to remove. + /// when the contributor was registered. + /// is null. + public bool Unregister(IConformanceContributor contributor) + { + if (contributor == null) + { + throw new ArgumentNullException(nameof(contributor)); + } + + bool removed; + lock (m_lock) + { + removed = m_contributors.Remove(contributor); + if (removed) + { + m_registrationVersion++; + } + } + if (removed) + { + Aggregate(); + } + return removed; + } + + /// + /// Reads every registered contributor again and publishes the + /// aggregated conformance units and server profiles to the address + /// space through the diagnostics node manager. /// /// A cancellation token. public async ValueTask PublishAsync(CancellationToken cancellationToken = default) { - ArrayOf units; - ArrayOf profiles; - - lock (m_lock) + // Publishing is serialized, so the address space ends with the + // set read last. + await m_publishLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - var orderedUnits = new List(m_conformanceUnits); - orderedUnits.Sort( - (left, right) => string.CompareOrdinal(left.Name, right.Name)); - units = orderedUnits.ToArrayOf(); - profiles = new List(m_serverProfiles).ToArrayOf(); + (ArrayOf units, ArrayOf profiles) = Aggregate(); + await m_server.DiagnosticsNodeManager + .PublishConformanceUnitsAsync(units, profiles, cancellationToken) + .ConfigureAwait(false); } + finally + { + m_publishLock.Release(); + } + } - await m_server.DiagnosticsNodeManager - .PublishConformanceUnitsAsync(units, profiles, cancellationToken) - .ConfigureAwait(false); + /// + /// Reads the contributors and replaces the aggregated sets. + /// + /// + /// The contributors are read outside the lock: computing what they + /// support may take locks of their own. A registration that changes + /// while they are read makes the read stale, so it is repeated with + /// the current contributors rather than replacing the set that + /// registration already committed. + /// + private (ArrayOf Units, ArrayOf Profiles) Aggregate() + { + while (true) + { + IConformanceContributor[] contributors; + long registrationVersion; + lock (m_lock) + { + contributors = [.. m_contributors]; + registrationVersion = m_registrationVersion; + } + + var units = new HashSet(); + var profiles = new HashSet(StringComparer.Ordinal); + var orderedProfiles = new List(); + foreach (IConformanceContributor contributor in contributors) + { + foreach (QualifiedName unit in contributor.ConformanceUnits) + { + if (!unit.IsNull) + { + units.Add(unit); + } + } + foreach (string profile in contributor.ServerProfiles) + { + if (!string.IsNullOrEmpty(profile) && profiles.Add(profile)) + { + orderedProfiles.Add(profile); + } + } + } + + lock (m_lock) + { + if (registrationVersion != m_registrationVersion) + { + continue; + } + m_conformanceUnits = units; + } + + var orderedUnits = new List(units); + orderedUnits.Sort((left, right) => string.CompareOrdinal(left.Name, right.Name)); + return (orderedUnits.ToArrayOf(), orderedProfiles.ToArrayOf()); + } } private readonly Lock m_lock = new(); + private readonly SemaphoreSlim m_publishLock = new(1, 1); private readonly IServerInternal m_server; - private readonly HashSet m_conformanceUnits; - private readonly HashSet m_serverProfiles; + private readonly List m_contributors = []; + private HashSet m_conformanceUnits; + private long m_registrationVersion; + private bool m_disposed; } } diff --git a/tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs b/tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs index bf22a8a5aa..4b38ac51ff 100644 --- a/tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs +++ b/tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs @@ -27,6 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -181,9 +182,239 @@ namespace Opc.Ua.Server.Tests Assert.That(manager.IsSupported(QualifiedName.Null), Is.False); } + [Test] + public async Task PublishWithdrawsWhatAContributorNoLongerReportsAsync() + { + var published = new List<(ArrayOf Units, ArrayOf Profiles)>(); + var diagnostics = new Mock(); + diagnostics + .Setup(d => d.PublishConformanceUnitsAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Callback, ArrayOf, CancellationToken>( + (units, profiles, _) => published.Add((units, profiles))) + .Returns(default(ValueTask)); + var server = new Mock(); + server.Setup(s => s.DiagnosticsNodeManager).Returns(diagnostics.Object); + using var manager = new ConformanceUnitsManager(server.Object); + + var contributor = new MutableContributor + { + ConformanceUnits = [new("Kept Unit"), new("Withdrawn Unit")], + ServerProfiles = ["urn:profile:kept", "urn:profile:withdrawn"] + }; + manager.Register(contributor); + await manager.PublishAsync(CancellationToken.None).ConfigureAwait(false); + + contributor.ConformanceUnits = [new("Kept Unit")]; + contributor.ServerProfiles = ["urn:profile:kept"]; + await manager.PublishAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.That(published, Has.Count.EqualTo(2)); + Assert.That(NamesOf(published[0].Units), Is.EqualTo(s_keptAndWithdrawnUnitNames)); + Assert.That(NamesOf(published[1].Units), Is.EqualTo(s_keptUnitNames)); + Assert.That(published[1].Profiles.ToArray(), Is.EqualTo(s_keptProfiles)); + Assert.That(manager.IsSupported(new QualifiedName("Withdrawn Unit")), Is.False); + Assert.That(manager.IsSupported(new QualifiedName("Kept Unit")), Is.True); + } + + [Test] + public async Task RegisteringAgainReadsTheContributorOnceAsync() + { + var diagnostics = new Mock(); + diagnostics + .Setup(d => d.PublishConformanceUnitsAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns(default(ValueTask)); + var server = new Mock(); + server.Setup(s => s.DiagnosticsNodeManager).Returns(diagnostics.Object); + using var manager = new ConformanceUnitsManager(server.Object); + + var contributor = new MutableContributor { ConformanceUnits = [new("Unit")] }; + manager.Register(contributor); + manager.Register(contributor); + contributor.Reads = 0; + await manager.PublishAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.That(contributor.Reads, Is.EqualTo(1)); + } + + [Test] + public void RegisteringAgainNeitherReadsNorChangesTheSupportedUnits() + { + var server = new Mock(); + using var manager = new ConformanceUnitsManager(server.Object); + + var contributor = new MutableContributor { ConformanceUnits = [new("Registered Unit")] }; + manager.Register(contributor); + contributor.ConformanceUnits = [new("Changed Unit")]; + contributor.Reads = 0; + manager.Register(contributor); + + Assert.That(contributor.Reads, Is.Zero); + Assert.That(manager.IsSupported(new QualifiedName("Registered Unit")), Is.True); + Assert.That(manager.IsSupported(new QualifiedName("Changed Unit")), Is.False); + } + + [Test] + public async Task UnregisterDuringAPublishIsNotUndoneByTheStaleReadAsync() + { + ArrayOf publishedUnits = default; + var diagnostics = new Mock(); + diagnostics + .Setup(d => d.PublishConformanceUnitsAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Callback, ArrayOf, CancellationToken>( + (units, _, _) => publishedUnits = units) + .Returns(default(ValueTask)); + var server = new Mock(); + server.Setup(s => s.DiagnosticsNodeManager).Returns(diagnostics.Object); + using var manager = new ConformanceUnitsManager(server.Object); + using var withdrawn = new BlockingContributor([new("Withdrawn Unit")]); + manager.Register(withdrawn); + manager.Register(new FakeContributor(units: [new("Kept Unit")], profiles: [])); + + // The publish snapshots both contributors and blocks while reading + // the first; it is unregistered before the read completes. + withdrawn.BlockNextRead(); + Task publish = Task.Run(async () => await manager.PublishAsync().ConfigureAwait(false)); + Assert.That(withdrawn.WaitUntilReading(TimeSpan.FromSeconds(30)), Is.True); + Assert.That(manager.Unregister(withdrawn), Is.True); + Assert.That(manager.IsSupported(new QualifiedName("Withdrawn Unit")), Is.False); + withdrawn.CompleteRead(); + await publish.ConfigureAwait(false); + + Assert.That(manager.IsSupported(new QualifiedName("Withdrawn Unit")), Is.False); + Assert.That(manager.IsSupported(new QualifiedName("Kept Unit")), Is.True); + Assert.That(NamesOf(publishedUnits), Is.EqualTo(s_keptUnitNames)); + } + + [Test] + public async Task UnregisterWithdrawsTheUnitsOfTheContributorAsync() + { + ArrayOf publishedUnits = default; + var diagnostics = new Mock(); + diagnostics + .Setup(d => d.PublishConformanceUnitsAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Callback, ArrayOf, CancellationToken>( + (units, _, _) => publishedUnits = units) + .Returns(default(ValueTask)); + var server = new Mock(); + server.Setup(s => s.DiagnosticsNodeManager).Returns(diagnostics.Object); + using var manager = new ConformanceUnitsManager(server.Object); + + var first = new FakeContributor(units: [new("First Unit")], profiles: []); + var second = new FakeContributor(units: [new("Second Unit")], profiles: []); + manager.Register(first); + manager.Register(second); + + Assert.That(manager.Unregister(first), Is.True); + Assert.That(manager.Unregister(first), Is.False); + await manager.PublishAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.That(NamesOf(publishedUnits), Is.EqualTo(s_secondUnitNames)); + Assert.That(manager.IsSupported(new QualifiedName("First Unit")), Is.False); + Assert.That(() => manager.Unregister(null!), Throws.ArgumentNullException); + } + + private static List NamesOf(ArrayOf units) + { + var names = new List(); + foreach (QualifiedName unit in units) + { + names.Add(unit.Name); + } + return names; + } + + private static readonly string[] s_keptAndWithdrawnUnitNames = ["Kept Unit", "Withdrawn Unit"]; + private static readonly string[] s_keptUnitNames = ["Kept Unit"]; + private static readonly string[] s_keptProfiles = ["urn:profile:kept"]; + private static readonly string[] s_secondUnitNames = ["Second Unit"]; + private static readonly string[] s_expectedSortedUnitNames = ["Alpha Unit", "Mid Unit", "Zeta Unit"]; + private sealed class MutableContributor : IConformanceContributor + { + public int Reads { get; set; } + + public ArrayOf ConformanceUnits + { + get + { + Reads++; + return m_units; + } + set => m_units = value; + } + + public ArrayOf ServerProfiles { get; set; } + + private ArrayOf m_units; + } + + /// + /// A contributor whose next read of its units blocks until the test + /// completes it, so a registration change can happen during the read. + /// + private sealed class BlockingContributor : IConformanceContributor, IDisposable + { + public BlockingContributor(ArrayOf units) + { + m_units = units; + } + + public ArrayOf ConformanceUnits + { + get + { + if (Interlocked.Exchange(ref m_blockNextRead, 0) == 1) + { + m_reading.Set(); + m_completeRead.Wait(TimeSpan.FromSeconds(30)); + } + return m_units; + } + } + + public ArrayOf ServerProfiles => []; + + public void BlockNextRead() + { + Interlocked.Exchange(ref m_blockNextRead, 1); + } + + public bool WaitUntilReading(TimeSpan timeout) + { + return m_reading.Wait(timeout); + } + + public void CompleteRead() + { + m_completeRead.Set(); + } + + public void Dispose() + { + m_reading.Dispose(); + m_completeRead.Dispose(); + } + + private readonly ArrayOf m_units; + private readonly ManualResetEventSlim m_reading = new(); + private readonly ManualResetEventSlim m_completeRead = new(); + private int m_blockNextRead; + } + private sealed class FakeContributor : IConformanceContributor { public FakeContributor(ArrayOf units, ArrayOf profiles) diff --git a/tests/Opc.Ua.Server.Tests/DiagnosticsNodeManagerTests.cs b/tests/Opc.Ua.Server.Tests/DiagnosticsNodeManagerTests.cs index 0adcbae29f..983bc70e78 100644 --- a/tests/Opc.Ua.Server.Tests/DiagnosticsNodeManagerTests.cs +++ b/tests/Opc.Ua.Server.Tests/DiagnosticsNodeManagerTests.cs @@ -43,6 +43,11 @@ namespace Opc.Ua.Server.Tests [Parallelizable] public class DiagnosticsNodeManagerTests { + private static readonly string[] s_declaredAndContributedProfiles = + ["urn:profile:declared", "urn:profile:contributed"]; + + private static readonly string[] s_declaredProfiles = ["urn:profile:declared"]; + private Mock m_serverMock; private Mock m_coreNodeManagerMock; private Mock m_subscriptionManagerMock; @@ -122,6 +127,36 @@ namespace Opc.Ua.Server.Tests Assert.That(resendData.OnCallMethod, Is.Not.Null, "ResendData OnCallMethod should be wired."); } + [Test] + public async Task PublishConformanceUnits_KeepsDeclaredProfilesAndWithdrawsContributedOnesAsync() + { + var config = new ApplicationConfiguration { ServerConfiguration = new ServerConfiguration() }; + SetupServerMock(); + + using var manager = new DiagnosticsNodeManager(m_serverMock.Object, config, NullLogger.Instance); + await manager.CreateAddressSpaceAsync(new Dictionary>()).ConfigureAwait(false); + BaseVariableState profiles = manager.FindPredefinedNode( + VariableIds.Server_ServerCapabilities_ServerProfileArray); + BaseVariableState units = manager.FindPredefinedNode( + VariableIds.Server_ServerCapabilities_ConformanceUnits); + ArrayOf declared = ["urn:profile:declared"]; + profiles.Value = Variant.From(declared); + + await manager.PublishConformanceUnitsAsync( + [new QualifiedName("Contributed Unit")], + ["urn:profile:contributed", "urn:profile:declared"]).ConfigureAwait(false); + string[] first = profiles.Value.TryGetValue(out ArrayOf afterFirst) ? afterFirst.ToArray()! : []; + + await manager.PublishConformanceUnitsAsync([], []).ConfigureAwait(false); + string[] second = profiles.Value.TryGetValue(out ArrayOf afterSecond) ? afterSecond.ToArray()! : []; + + Assert.That(first, Is.EqualTo(s_declaredAndContributedProfiles)); + Assert.That(second, Is.EqualTo(s_declaredProfiles)); + Assert.That( + units.Value.TryGetValue(out ArrayOf unitsAfter) && unitsAfter.Count == 0, + Is.True); + } + [Test] public async Task CreateAddressSpace_DurableSubscriptionsEnabled_SetsOnCallAsync() { diff --git a/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs index bb3f1c548b..1b2f6bbc26 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs @@ -29,7 +29,6 @@ using System; using System.Collections.Generic; -using Moq; using NUnit.Framework; using Opc.Ua.Server.Fluent; @@ -315,5 +314,123 @@ namespace Opc.Ua.Server.Tests.Fluent Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); } + + [Test] + public void CreateAlarmAttachesTheFactoryStateLikeTheDedicatedHelpers() + { + (NodeManagerBuilder b, BaseObjectState root, _) = CreateBuilder(); + INodeBuilder nb = b.Node(new NodeId("Root", kNs)); + NodeState? handedParent = null; + + IAlarmBuilder ab = nb.CreateAlarm( + new QualifiedName("Trip", kNs), + parent => + { + handedParent = parent; + return new TripAlarmState(parent); + }); + + var references = new List(); + root.GetReferences(b.Context, references); + Assert.Multiple(() => + { + Assert.That(handedParent, Is.SameAs(root)); + Assert.That(ab.Alarm, Is.InstanceOf()); + Assert.That(ab.Builder, Is.SameAs(nb)); + Assert.That(ab.Alarm.Parent, Is.SameAs(root)); + Assert.That(ab.Alarm.BrowseName, Is.EqualTo(new QualifiedName("Trip", kNs))); + Assert.That(ab.Alarm.ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.HasCondition)); + Assert.That( + ab.Alarm.NodeId.IdentifierAsString, + Does.Contain("Root").And.Contain("Trip")); + Assert.That(ab.Alarm.TypeDefinitionId, Is.EqualTo(ObjectTypeIds.TripAlarmType)); + Assert.That(ab.Alarm.EnabledState!.Id!.Value, Is.True); + Assert.That(ab.Alarm.SourceNode!.Value, Is.EqualTo(root.NodeId)); + Assert.That(ab.Alarm.SourceName!.Value, Is.EqualTo("Root")); + Assert.That(ab.Alarm.ConditionName!.Value, Is.EqualTo("Trip")); + Assert.That(root.EventNotifier & EventNotifiers.SubscribeToEvents, Is.Not.Zero); + Assert.That( + references, + Has.Exactly(1).Matches(reference => + reference.ReferenceTypeId == ReferenceTypeIds.HasEventSource && + !reference.IsInverse && + reference.TargetId == ab.Alarm.NodeId)); + }); + } + + [Test] + public void CreateAlarmAcceptsAConditionThatIsNoAlarm() + { + (NodeManagerBuilder b, _, _) = CreateBuilder(); + INodeBuilder nb = b.Node(new NodeId("Root", kNs)); + + IAlarmBuilder ab = nb.CreateAlarm( + new QualifiedName("Inspection", kNs), + parent => new AcknowledgeableConditionState(parent)) + .OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good); + + ServiceResultException ex = Assert.Throws( + () => ab.WithLimits(high: 1.0)); + Assert.Multiple(() => + { + Assert.That(ab.Alarm.OnAcknowledge, Is.Not.Null); + Assert.That(ab.Alarm.TypeDefinitionId, Is.EqualTo(ObjectTypeIds.AcknowledgeableConditionType)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + }); + } + + [Test] + public void CreateAlarmNullArgsThrow() + { + (NodeManagerBuilder b, _, _) = CreateBuilder(); + INodeBuilder nb = b.Node(new NodeId("Root", kNs)); + INodeBuilder nullBuilder = null!; + + Assert.Multiple(() => + { + Assert.Throws(() => nullBuilder.CreateAlarm( + new QualifiedName("X", kNs), + parent => new OffNormalAlarmState(parent))); + Assert.Throws(() => nb.CreateAlarm( + QualifiedName.Null, + parent => new OffNormalAlarmState(parent))); + Assert.Throws(() => nb.CreateAlarm( + new QualifiedName("X", kNs), + null!)); + }); + } + + [Test] + public void CreateAlarmRejectsAFactoryWithoutAnAlarm() + { + (NodeManagerBuilder b, BaseObjectState root, _) = CreateBuilder(); + INodeBuilder nb = b.Node(new NodeId("Root", kNs)); + + Assert.Throws(() => nb.CreateAlarm( + new QualifiedName("X", kNs), + _ => null!)); + + var references = new List(); + root.GetReferences(b.Context, references); + Assert.That( + references, + Has.None.Matches(reference => + reference.ReferenceTypeId == ReferenceTypeIds.HasEventSource), + "a refused alarm leaves the parent untouched"); + } + + [Test] + public void CreateAlarmOnVariableThrowsBadTypeMismatch() + { + (NodeManagerBuilder b, _, BaseDataVariableState src) = CreateBuilder(); + IVariableBuilder variable = b.Variable(src.NodeId); + + ServiceResultException ex = Assert.Throws( + () => variable.CreateAlarm( + new QualifiedName("Trip", kNs), + parent => new TripAlarmState(parent))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } } } diff --git a/tests/Opc.Ua.Server.Tests/Fluent/FluentAlarmRegistrationIntegrationTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/FluentAlarmRegistrationIntegrationTests.cs index 354f1520cf..60a071cef9 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/FluentAlarmRegistrationIntegrationTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/FluentAlarmRegistrationIntegrationTests.cs @@ -79,6 +79,32 @@ namespace Opc.Ua.Server.Tests.Fluent Is.SameAs(alarm.Alarm)); } + [Test] + public async Task CreateAlarmIsBrowsableAndFindableAsync() + { + using Harness h = CreateHarness(); + + IAlarmBuilder alarm = h.Builder.Node(h.Source.NodeId) + .CreateAlarm( + new QualifiedName("ValveDiscrepancy", h.NamespaceIndex), + parent => new DiscrepancyAlarmState(parent)); + + IList references = await BrowseAsync(h, h.Source.NodeId).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + references.Select(reference => reference.BrowseName), + Has.Member(alarm.Alarm.BrowseName)); + Assert.That( + h.Manager.FindPredefinedNodePublic(alarm.Alarm.NodeId), + Is.SameAs(alarm.Alarm)); + Assert.That( + h.Root.EventNotifier & EventNotifiers.SubscribeToEvents, + Is.EqualTo(EventNotifiers.SubscribeToEvents)); + }); + } + [Test] public void CreateLimitAlarmPromotesParentAndAncestorsAsEventNotifiers() { diff --git a/tests/Opc.Ua.Server.Tests/Fluent/NodeAttachBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/NodeAttachBuilderExtensionsTests.cs index b99885882c..35705c4e9a 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/NodeAttachBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/NodeAttachBuilderExtensionsTests.cs @@ -380,6 +380,38 @@ namespace Opc.Ua.Server.Tests.Fluent }); } + [Test] + public async Task GenericAlarmWiringIsUndoneOnTeardownAsync() + { + using var manager = new TestBehaviorManager(); + manager.SeedSiblings(1); + + NodeManagerBuilder builder = manager.NewBuilder(); + INodeBuilder parent = builder.Node(new NodeId(100u, 1)); + var parentObject = (BaseObjectState)parent.Node; + + TripAlarmState alarm = parent + .CreateAlarm(new QualifiedName("Trip", 1), node => new TripAlarmState(node)) + .Alarm; + + await manager.ActivateAsync().ConfigureAwait(false); + Assert.That(alarm.EnabledState?.Id?.Value, Is.True, "attaching an alarm enables it"); + + await manager.DeleteAddressSpaceAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + alarm.EnabledState?.Id?.Value, + Is.False, + "teardown must disable the condition"); + Assert.That( + parentObject.EventNotifier & EventNotifiers.SubscribeToEvents, + Is.Zero, + "teardown must clear the notifier bit it set"); + }); + } + [Test] public void AlarmWiringIsUnwoundWhenALaterBehaviorFailsActivation() {