Server: public CreateAlarm<TState> fluent overload, and conformance units that follow their contributors (#4650)

# 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<TState>` 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<TState>(INodeBuilder, QualifiedName, Func<NodeState,
TState>)` 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<TState>`; 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<TState>` 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 <marcschier@users.noreply.github.com>
This commit is contained in:
Klaus Landsdorf
2026-10-04 19:07:20 +02:00
committed by GitHub
parent 4c1c3fd244
commit 89bbc3bd75
12 changed files with 677 additions and 55 deletions
+13
View File
@@ -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<TState>` 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<bool>.OnRisingEdge` / `.OnFallingEdge` register
+17
View File
@@ -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:
@@ -1760,11 +1760,6 @@ namespace Opc.Ua.Server
conformanceUnitsNode.ClearChangeMasks(SystemContext, false);
}
if (serverProfiles.Count == 0)
{
return;
}
BaseVariableState? profileArrayNode = FindPredefinedNode<BaseVariableState>(
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<string>();
if (profileArrayNode.Value.TryGetValue(out ArrayOf<string> 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<string> 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<string>(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<uint, ISampledDataChangeMonitoredItem> m_sampledItems;
private readonly double m_minimumSamplingInterval;
private HistoryServerCapabilitiesState? m_historyCapabilities;
private List<string>? m_declaredServerProfiles;
/// <summary>
/// Aggregates the per-node capabilities advertised by every
@@ -60,8 +60,9 @@ namespace Opc.Ua.Server
/// Publishes the supported conformance units to
/// <c>Server/ServerCapabilities/ConformanceUnits</c> and merges the
/// enabled server profiles into <c>Server/ServerCapabilities/ServerProfileArray</c>
/// (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.
/// </summary>
ValueTask PublishConformanceUnitsAsync(
ArrayOf<QualifiedName> conformanceUnits,
@@ -129,7 +129,8 @@ namespace Opc.Ua.Server.Fluent
/// Strongly-typed fluent builder for an alarm/condition state
/// instance. Returned by the <c>CreateLimitAlarm</c> /
/// <c>CreateExclusiveLimitAlarm</c> / <c>CreateOffNormalAlarm</c>
/// helpers on <see cref="INodeBuilder"/>.
/// helpers on <see cref="INodeBuilder"/>, and by <c>CreateAlarm</c> for
/// any other condition type.
/// </summary>
/// <remarks>
/// <para>
@@ -251,6 +252,46 @@ namespace Opc.Ua.Server.Fluent
return new AlarmBuilder<OffNormalAlarmState>(parent, alarm);
}
/// <summary>
/// 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 <c>SourceNode</c>, <c>SourceName</c> and
/// event notifier, and a fluent node manager releases all of that
/// again on teardown.
/// </summary>
/// <typeparam name="TState">The condition state the factory creates.</typeparam>
/// <param name="parent">The Object the alarm is created on.</param>
/// <param name="browseName">The browse name of the alarm.</param>
/// <param name="factory">
/// Creates the uninitialized alarm for the parent it is handed, for
/// example <c>parent =&gt; new OffNormalAlarmState(parent)</c>. The
/// builder assigns the identity and initializes the alarm.
/// </param>
/// <returns>A builder for further configuration of the alarm.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="parent"/>, <paramref name="browseName"/> or
/// <paramref name="factory"/> is <c>null</c>.
/// </exception>
/// <exception cref="ServiceResultException">
/// <see cref="StatusCodes.BadTypeMismatch"/> when the parent is not an
/// Object.
/// </exception>
/// <exception cref="InvalidOperationException">
/// <paramref name="factory"/> returned <c>null</c>.
/// </exception>
public static IAlarmBuilder<TState> CreateAlarm<TState>(
this INodeBuilder parent,
QualifiedName browseName,
Func<NodeState, TState> factory)
where TState : ConditionState
{
TState alarm = AttachAlarm(parent, browseName, factory);
return new AlarmBuilder<TState>(parent, alarm);
}
/// <summary>
/// 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
/// <typeparam name="TState">The concrete alarm state created by the factory.</typeparam>
/// <exception cref="ArgumentNullException"><paramref name="parent"/> is <c>null</c>.</exception>
/// <exception cref="ServiceResultException"></exception>
/// <exception cref="InvalidOperationException">The factory returned <c>null</c>.</exception>
private static TState AttachAlarm<TState>(
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);
@@ -47,11 +47,12 @@ namespace Opc.Ua.Server.Historian
/// <para>
/// <see cref="ConformanceUnits"/> and <see cref="ServerProfiles"/> are
/// a snapshot computed by the last <see cref="RefreshAsync"/> call
/// (empty until the first call). <see cref="ConformanceUnitsManager.Register"/>
/// reads a contributor's properties once, so callers must
/// <see cref="RefreshAsync"/> before registering, and again — followed
/// by re-registering — whenever a provider is added, removed, or its
/// capabilities change.
/// (empty until the first call). <see cref="ConformanceUnitsManager"/>
/// reads a registered contributor's properties on every
/// <see cref="ConformanceUnitsManager.PublishAsync"/>, so callers must
/// <see cref="RefreshAsync"/> before the server publishes, and again —
/// followed by <see cref="ConformanceUnitsManager.PublishAsync"/> —
/// whenever a provider is added, removed, or its capabilities change.
/// </para>
/// <para>
/// Only Server-side profiles are considered; this contributor
@@ -40,6 +40,7 @@ namespace Opc.Ua.Server
/// and <c>Server/ServerCapabilities/ServerProfileArray</c> (OPC UA Part 7).
/// </summary>
/// <remarks>
/// <para>
/// The published set is derived from the contributions of every registered
/// node manager (or other feature) implementing
/// <see cref="IConformanceContributor"/> 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 <see cref="IDiagnosticsNodeManager"/>,
/// mirroring how <see cref="ModellingRulesManager"/> delegates to it.
/// </para>
/// <para>
/// The manager keeps the registered contributors and reads them again on
/// every <see cref="PublishAsync"/>, 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.
/// </para>
/// </remarks>
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<string>(StringComparer.Ordinal);
}
/// <summary>
@@ -75,7 +83,11 @@ namespace Opc.Ua.Server
/// </summary>
protected virtual void Dispose(bool disposing)
{
// No unmanaged resources held.
if (disposing && !m_disposed)
{
m_disposed = true;
m_publishLock.Dispose();
}
}
/// <summary>
@@ -85,7 +97,8 @@ namespace Opc.Ua.Server
/// The browse name of the conformance unit.
/// </param>
/// <returns>
/// True if the conformance unit is currently advertised.
/// True if the conformance unit is supported as of the last
/// registration or publish.
/// </returns>
public bool IsSupported(QualifiedName conformanceUnit)
{
@@ -101,9 +114,14 @@ namespace Opc.Ua.Server
}
/// <summary>
/// Registers the conformance units and server profiles a contributor
/// enables.
/// Registers a contributor whose conformance units and server profiles
/// the server advertises.
/// </summary>
/// <remarks>
/// Registering a contributor again has no effect and does not read it
/// again; every contributor is read again on each
/// <see cref="PublishAsync"/>.
/// </remarks>
/// <param name="contributor">
/// The contributor whose supported units and profiles are added.
/// </param>
@@ -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();
}
/// <summary>
/// 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 <see cref="PublishAsync"/> unless another contributor reports
/// them.
/// </summary>
/// <param name="contributor">The contributor to remove.</param>
/// <returns><see langword="true"/> when the contributor was registered.</returns>
/// <exception cref="ArgumentNullException"><paramref name="contributor"/> is <c>null</c>.</exception>
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;
}
/// <summary>
/// Reads every registered contributor again and publishes the
/// aggregated conformance units and server profiles to the address
/// space through the diagnostics node manager.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
public async ValueTask PublishAsync(CancellationToken cancellationToken = default)
{
ArrayOf<QualifiedName> units;
ArrayOf<string> 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<QualifiedName>(m_conformanceUnits);
orderedUnits.Sort(
(left, right) => string.CompareOrdinal(left.Name, right.Name));
units = orderedUnits.ToArrayOf();
profiles = new List<string>(m_serverProfiles).ToArrayOf();
(ArrayOf<QualifiedName> units, ArrayOf<string> 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);
/// <summary>
/// Reads the contributors and replaces the aggregated sets.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private (ArrayOf<QualifiedName> Units, ArrayOf<string> Profiles) Aggregate()
{
while (true)
{
IConformanceContributor[] contributors;
long registrationVersion;
lock (m_lock)
{
contributors = [.. m_contributors];
registrationVersion = m_registrationVersion;
}
var units = new HashSet<QualifiedName>();
var profiles = new HashSet<string>(StringComparer.Ordinal);
var orderedProfiles = new List<string>();
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<QualifiedName>(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<QualifiedName> m_conformanceUnits;
private readonly HashSet<string> m_serverProfiles;
private readonly List<IConformanceContributor> m_contributors = [];
private HashSet<QualifiedName> m_conformanceUnits;
private long m_registrationVersion;
private bool m_disposed;
}
}
@@ -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<QualifiedName> Units, ArrayOf<string> Profiles)>();
var diagnostics = new Mock<IDiagnosticsNodeManager>();
diagnostics
.Setup(d => d.PublishConformanceUnitsAsync(
It.IsAny<ArrayOf<QualifiedName>>(),
It.IsAny<ArrayOf<string>>(),
It.IsAny<CancellationToken>()))
.Callback<ArrayOf<QualifiedName>, ArrayOf<string>, CancellationToken>(
(units, profiles, _) => published.Add((units, profiles)))
.Returns(default(ValueTask));
var server = new Mock<IServerInternal>();
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<IDiagnosticsNodeManager>();
diagnostics
.Setup(d => d.PublishConformanceUnitsAsync(
It.IsAny<ArrayOf<QualifiedName>>(),
It.IsAny<ArrayOf<string>>(),
It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
var server = new Mock<IServerInternal>();
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<IServerInternal>();
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<QualifiedName> publishedUnits = default;
var diagnostics = new Mock<IDiagnosticsNodeManager>();
diagnostics
.Setup(d => d.PublishConformanceUnitsAsync(
It.IsAny<ArrayOf<QualifiedName>>(),
It.IsAny<ArrayOf<string>>(),
It.IsAny<CancellationToken>()))
.Callback<ArrayOf<QualifiedName>, ArrayOf<string>, CancellationToken>(
(units, _, _) => publishedUnits = units)
.Returns(default(ValueTask));
var server = new Mock<IServerInternal>();
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<QualifiedName> publishedUnits = default;
var diagnostics = new Mock<IDiagnosticsNodeManager>();
diagnostics
.Setup(d => d.PublishConformanceUnitsAsync(
It.IsAny<ArrayOf<QualifiedName>>(),
It.IsAny<ArrayOf<string>>(),
It.IsAny<CancellationToken>()))
.Callback<ArrayOf<QualifiedName>, ArrayOf<string>, CancellationToken>(
(units, _, _) => publishedUnits = units)
.Returns(default(ValueTask));
var server = new Mock<IServerInternal>();
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<string> NamesOf(ArrayOf<QualifiedName> units)
{
var names = new List<string>();
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<QualifiedName> ConformanceUnits
{
get
{
Reads++;
return m_units;
}
set => m_units = value;
}
public ArrayOf<string> ServerProfiles { get; set; }
private ArrayOf<QualifiedName> m_units;
}
/// <summary>
/// A contributor whose next read of its units blocks until the test
/// completes it, so a registration change can happen during the read.
/// </summary>
private sealed class BlockingContributor : IConformanceContributor, IDisposable
{
public BlockingContributor(ArrayOf<QualifiedName> units)
{
m_units = units;
}
public ArrayOf<QualifiedName> ConformanceUnits
{
get
{
if (Interlocked.Exchange(ref m_blockNextRead, 0) == 1)
{
m_reading.Set();
m_completeRead.Wait(TimeSpan.FromSeconds(30));
}
return m_units;
}
}
public ArrayOf<string> 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<QualifiedName> 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<QualifiedName> units, ArrayOf<string> profiles)
@@ -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<IServerInternal> m_serverMock;
private Mock<ICoreNodeManager> m_coreNodeManagerMock;
private Mock<ISubscriptionManager> 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<NodeId, IList<IReference>>()).ConfigureAwait(false);
BaseVariableState profiles = manager.FindPredefinedNode<BaseVariableState>(
VariableIds.Server_ServerCapabilities_ServerProfileArray);
BaseVariableState units = manager.FindPredefinedNode<BaseVariableState>(
VariableIds.Server_ServerCapabilities_ConformanceUnits);
ArrayOf<string> 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<string> afterFirst) ? afterFirst.ToArray()! : [];
await manager.PublishConformanceUnitsAsync([], []).ConfigureAwait(false);
string[] second = profiles.Value.TryGetValue(out ArrayOf<string> afterSecond) ? afterSecond.ToArray()! : [];
Assert.That(first, Is.EqualTo(s_declaredAndContributedProfiles));
Assert.That(second, Is.EqualTo(s_declaredProfiles));
Assert.That(
units.Value.TryGetValue(out ArrayOf<QualifiedName> unitsAfter) && unitsAfter.Count == 0,
Is.True);
}
[Test]
public async Task CreateAddressSpace_DurableSubscriptionsEnabled_SetsOnCallAsync()
{
@@ -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<TripAlarmState> ab = nb.CreateAlarm(
new QualifiedName("Trip", kNs),
parent =>
{
handedParent = parent;
return new TripAlarmState(parent);
});
var references = new List<IReference>();
root.GetReferences(b.Context, references);
Assert.Multiple(() =>
{
Assert.That(handedParent, Is.SameAs(root));
Assert.That(ab.Alarm, Is.InstanceOf<TripAlarmState>());
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<IReference>(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<AcknowledgeableConditionState> ab = nb.CreateAlarm(
new QualifiedName("Inspection", kNs),
parent => new AcknowledgeableConditionState(parent))
.OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => 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<ArgumentNullException>(() => nullBuilder.CreateAlarm(
new QualifiedName("X", kNs),
parent => new OffNormalAlarmState(parent)));
Assert.Throws<ArgumentNullException>(() => nb.CreateAlarm(
QualifiedName.Null,
parent => new OffNormalAlarmState(parent)));
Assert.Throws<ArgumentNullException>(() => nb.CreateAlarm<OffNormalAlarmState>(
new QualifiedName("X", kNs),
null!));
});
}
[Test]
public void CreateAlarmRejectsAFactoryWithoutAnAlarm()
{
(NodeManagerBuilder b, BaseObjectState root, _) = CreateBuilder();
INodeBuilder nb = b.Node(new NodeId("Root", kNs));
Assert.Throws<InvalidOperationException>(() => nb.CreateAlarm<OffNormalAlarmState>(
new QualifiedName("X", kNs),
_ => null!));
var references = new List<IReference>();
root.GetReferences(b.Context, references);
Assert.That(
references,
Has.None.Matches<IReference>(reference =>
reference.ReferenceTypeId == ReferenceTypeIds.HasEventSource),
"a refused alarm leaves the parent untouched");
}
[Test]
public void CreateAlarmOnVariableThrowsBadTypeMismatch()
{
(NodeManagerBuilder b, _, BaseDataVariableState src) = CreateBuilder();
IVariableBuilder<double> variable = b.Variable<double>(src.NodeId);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => variable.CreateAlarm(
new QualifiedName("Trip", kNs),
parent => new TripAlarmState(parent)));
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
}
}
}
@@ -79,6 +79,32 @@ namespace Opc.Ua.Server.Tests.Fluent
Is.SameAs(alarm.Alarm));
}
[Test]
public async Task CreateAlarmIsBrowsableAndFindableAsync()
{
using Harness h = CreateHarness();
IAlarmBuilder<DiscrepancyAlarmState> alarm = h.Builder.Node(h.Source.NodeId)
.CreateAlarm(
new QualifiedName("ValveDiscrepancy", h.NamespaceIndex),
parent => new DiscrepancyAlarmState(parent));
IList<ReferenceDescription> 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<DiscrepancyAlarmState>(alarm.Alarm.NodeId),
Is.SameAs(alarm.Alarm));
Assert.That(
h.Root.EventNotifier & EventNotifiers.SubscribeToEvents,
Is.EqualTo(EventNotifiers.SubscribeToEvents));
});
}
[Test]
public void CreateLimitAlarmPromotesParentAndAncestorsAsEventNotifiers()
{
@@ -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()
{