From 89bbc3bd75e62f998b3ff4fd29bcc4e5f9c72041 Mon Sep 17 00:00:00 2001 From: Klaus Landsdorf Date: Sun, 4 Oct 2026 19:07:20 +0200 Subject: [PATCH] Server: public CreateAlarm fluent overload, and conformance units that follow their contributors (#4650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Two changes to `Opc.Ua.Server`, one commit each. Both are independent of any companion model; the Asset Management Basics contribution (#4649) is the first code that needs them, but any companion node manager can use them. **1. `CreateAlarm` on `INodeBuilder`.** - The fluent alarm surface offers `CreateLimitAlarm`, `CreateExclusiveLimitAlarm` and `CreateOffNormalAlarm`. The pipeline behind them (`AttachAlarm`) is private, and `FluentNodeRegistration` is internal. - A node manager that needs any other condition type cannot reach that pipeline from outside the assembly. Examples are an alarm type of a companion specification, a `TripAlarmType` or `DiscrepancyAlarmType`, or a server-specific subtype that implements an interface such as `IRootCauseIndicationType`. Such a node manager has to copy the attach, notifier and release logic, or it loses the automatic release on teardown. - `CreateAlarm(INodeBuilder, QualifiedName, Func)` exposes the same pipeline for a state the caller's factory creates. The alarm is initialized, enabled and attached with `HasCondition`. The parent becomes its `SourceNode`, `SourceName` and event notifier. A fluent node manager releases all of it on teardown. - A factory that returns `null` fails with `InvalidOperationException` before anything is changed. The three existing helpers never take that path, so they behave as before. **2. `ConformanceUnitsManager` recomputes the advertised units from its contributors.** - On master the manager reads a contributor once, in `Register`, and adds its units and profiles to two sets that only grow. - A unit or profile that a contributor stops reporting therefore stays in `ConformanceUnits` and `ServerProfileArray` until the server restarts. One that it starts reporting after registration is never published. - The ServerCapabilities describe the current configuration of the server (OPC 10000-5 §6.3.2), so both have to follow the contributor. This matters for contributors whose support changes at runtime, for example a node manager whose units depend on the instances registered with it, or `HistorianConformanceContributor` after `RefreshAsync`. - The manager now keeps the registered contributors and reads all of them again on every `PublishAsync`: - `Register` is idempotent; - `Unregister` is new; - publishing is serialized (`SemaphoreSlim`), so the address space ends with the set read last; - the contributors are read outside the manager's lock. - `DiagnosticsNodeManager.PublishConformanceUnitsAsync` keeps the profiles that were in `ServerProfileArray` before the first publish, which are the configured ones, and replaces the contributed ones on every publish. **Behaviour change for custom contributors.** A profile or unit that no contributor reports any more is now withdrawn on the next publish. Registering a contributor twice no longer reads it twice; the result is the same set as before. A contributor whose sets are fixed at startup sees no difference. The DI, Machinery, Pumps, Scales, Robotics and Vision node managers register once at startup and publish the same sets as before (see Tests). ## Changes - `src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs`: `CreateAlarm`; the null check on the factory result in `AttachAlarm`. - `src/Opc.Ua.Server/Server/ConformanceUnitsManager.cs`: contributor list, `Unregister`, recompute in `PublishAsync`, publish gate, `Dispose` releases the gate. - `src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs`: configured profiles captured on the first publish, contributed profiles replaced. `IDiagnosticsNodeManager.cs` and `HistorianConformanceContributor.cs`: documentation only. - `docs/NodeManagers.md`: `CreateAlarm` next to the dedicated helpers. `docs/Profiles.md`: how contributed units and profiles are published and withdrawn. ## Tests - **New, part 1** (`Opc.Ua.Server.Tests`, `Fluent`): - `AlarmBuilderExtensionsTests`: `CreateAlarmAttachesTheFactoryStateLikeTheDedicatedHelpers`, `CreateAlarmAcceptsAConditionThatIsNoAlarm`, `CreateAlarmNullArgsThrow`, `CreateAlarmRejectsAFactoryWithoutAnAlarm`, `CreateAlarmOnVariableThrowsBadTypeMismatch`; - `FluentAlarmRegistrationIntegrationTests.CreateAlarmIsBrowsableAndFindableAsync`; - `NodeAttachBuilderExtensionsTests`: `GenericAlarmWiringIsUndoneOnTeardownAsync` (release on teardown). - **New, part 2** (`Opc.Ua.Server.Tests`): - `ConformanceUnitsManagerTests`: `PublishWithdrawsWhatAContributorNoLongerReportsAsync`, `RegisteringAgainReadsTheContributorOnceAsync`, `UnregisterWithdrawsTheUnitsOfTheContributorAsync`; - `DiagnosticsNodeManagerTests.PublishConformanceUnits_KeepsDeclaredProfilesAndWithdrawsContributedOnesAsync`. - **Fail without the change:** with the `src/` files of master, the part 1 tests and `UnregisterWithdrawsTheUnitsOfTheContributorAsync` do not compile (`CreateAlarm` and `Unregister` do not exist). With that test left out, `PublishWithdrawsWhatAContributorNoLongerReportsAsync` (the withdrawn unit is still published), `RegisteringAgainReadsTheContributorOnceAsync` (the contributor is not read on publish) and `PublishConformanceUnits_KeepsDeclaredProfilesAndWithdrawsContributedOnesAsync` (the contributed profile stays) fail; the seven existing `ConformanceUnitsManagerTests` pass. - **Existing contributors:** the conformance, facet, profile and compliance tests of the companion suites pass unchanged on net10.0: `Opc.Ua.Di.Tests` 33, `Opc.Ua.Machinery.Tests` 19, `Opc.Ua.Scales.Tests` 5, `Opc.Ua.Robotics.Tests` 128 (incl. `RobotIntentHostingComplianceTests`), `Opc.Ua.Vision.Tests` 9 (`VisionNodeManagerIntegrationTests`), and the whole `Opc.Ua.Pumps.Tests` suite (73). They check the units, facets and profiles each node manager advertises against what it built, and pass as they do on master. - `Opc.Ua.Server.Tests`: 7966 passed / 9 skipped / 0 failed on net10.0 (full suite); the affected tests (`Fluent`, `ConformanceUnitsManagerTests`, `DiagnosticsNodeManagerTests`, the historian and other conformance tests, 934) pass on net10.0 and net8.0 (macOS). - `Opc.Ua.Server` builds for net48, net8.0, net9.0 and net10.0 without new warnings (the only one, CA2213 in `SamplingGroup.cs`, is on master since #4629); `dotnet format` (whitespace, style, analyzers) reports nothing on the changed files; changed lines covered 70/70 (100 %) by `.azurepipelines/check-coverage.ps1` with the `Opc.Ua.Server.Tests` run on net10.0. - Fork CI (full matrix incl. macOS) and CodeQL: [CI run 37135986886](https://github.com/biancode/UA-.NETStandard/actions/runs/37135986886) and [CodeQL run 37135988487](https://github.com/biancode/UA-.NETStandard/actions/runs/37135988487). CodeQL is green; CI is green except `test-windows-net48 (11/32)`, the `Opc.Ua.Interop.Tests` legacy ComplexTypes check ("1 of N structures were not decoded") that came with #4630 and fails the same way on other open pull requests; this change does not touch ComplexTypes. ## Notes for review - The two commits are independent. If you prefer to discuss the behaviour change of part 2 separately, part 1 can go in on its own. - `ConformanceUnitsManager` stays a non-sealed public class, as on master. - The Robotics Intent and Vision node managers also merge their facet URIs into `ServerProfileArray` directly, at startup and when a controller host is added. They report the same URIs through `IConformanceContributor`, so a later publish keeps them. The entries they write before the first publish count as configured, as everything else that is in the array at that point. - `IServerInternal` does not expose the manager since the service-locator cleanup (#4184). A contributor that changes at runtime reaches it through `ServerInternalData.ConformanceUnitsManager`. If you would rather offer a narrower hook (for example a `RepublishConformanceAsync` on the server), we can add it in this PR. ## Related Issues - Part of #4649 ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. (Affected suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered by the fork CI's Windows net48 legs.) - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Marc Schier --- docs/NodeManagers.md | 13 + docs/Profiles.md | 17 ++ .../Diagnostics/DiagnosticsNodeManager.cs | 27 +- .../Diagnostics/IDiagnosticsNodeManager.cs | 5 +- .../Fluent/AlarmBuilderExtensions.cs | 47 +++- .../HistorianConformanceContributor.cs | 11 +- .../Server/ConformanceUnitsManager.cs | 169 ++++++++++--- .../ConformanceUnitsManagerTests.cs | 231 ++++++++++++++++++ .../DiagnosticsNodeManagerTests.cs | 35 +++ .../Fluent/AlarmBuilderExtensionsTests.cs | 119 ++++++++- ...FluentAlarmRegistrationIntegrationTests.cs | 26 ++ .../NodeAttachBuilderExtensionsTests.cs | 32 +++ 12 files changed, 677 insertions(+), 55 deletions(-) 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() {