Adopt the fluent builder's node-creation surface in the remaining hand-rolled node managers (#4460)

Closes #4429.

PR #4428 added the node-creation surface to `INodeManagerBuilder`. This
picks up the remaining candidates that needed a base-class change first,
and reports on the two that turn out not to be candidates at all.

## Converted

**`samples/WotCon/FlatTagServer/FlatTagNodeManager.cs`** — now derives
from `FluentNodeManagerBase`. The private `CreateObject` /
`CreateVariable` / `CreateMethod` helpers are gone, along with every
`pumpNodeId + ".Operational.Measurements.MassFlow"` concatenation; nodes
are staged with `AddObject` / `AddVariable<T>` / `AddMethod` and the
hand-maintained `externalReferences[ObjectsFolder]` entry is replaced by
`CompleteConfigureAsync`.

The `EventNotifier` bits and `AddRootNotifierAsync(pump)` are kept
verbatim — the pump is the notifier an aggregating server subscribes to,
and OPC 10000-3 only delivers the supervision conditions to a client
that can reach one.

`SupervisionSignal` loses its `namespaceIndex` and `tagPath` parameters
and derives both from the supervision Object it hangs off, so the signal
follows wherever that Object was staged.

**`samples/Redundancy/RedundantServer/HaSampleNodeManager.cs`** — same
shape. The `Counter` still reaches `EnableDistributedValueParticipation`
through `INodeBuilder.Node`. The variables keep their `Organizes`
reference type, which the fluent default would otherwise have made
`HasComponent`.

**`samples/DI/PumpDeviceIntegrationServer/OpenUsdComposition.cs`** —
both `AddPredefinedNodeAsync` sites stage on the builder now, threaded
through `ConfigureAsync`; `DiNodeManager.CreateAddressSpaceAsync`
already calls `RegisterAuthoredNodesAsync` between `ConfigureAsync` and
the reverse-reference pass, so no new plumbing was needed. Both methods
became synchronous. The NodeIds are unchanged because the hand
`SystemContext.NodeIdFactory.New(...)` calls and
`AssignInstanceChildNodeIds` were already doing exactly what staging
does.

`CreateRepresentedComponent` is left alone: it sits on
`CreatePumpAsync`, a public runtime API, and so is out of scope for the
same reason as the DI device builders.

## NodeIds are part of two samples' contracts

The issue expected the staged path to mint through the manager's
`INodeIdFactory`, and it does — but for two of these samples the
identifiers are a published contract, not an implementation detail:

- the aggregating client's checked-in Thing Descriptions under
`samples/WotCon/AggregationClient/Documents/` spell out
`nsu=…SourceA;s=Pump1.Identification.Manufacturer`;
- `samples/Redundancy/RedundantClient/Program.cs` addresses `new
NodeId("Counter", ns)` directly.

Both managers therefore override `New` for those nodes instead of taking
the default factory's derived identifier, which satisfies the acceptance
criterion that each manager keeps its current address-space shape.
FlatTag mints the full dotted browse path; the HA sample mints the bare
browse name, but only for the sample folder and its direct children.

Two things are worth knowing for anyone writing such an override.

**It has to keep an identifier the caller already chose.**
`DefaultNodeIdFactory.New` returns `node.NodeId` unchanged when it is
non-null and in the manager's own namespace. Without that same guard,
`NodeState.Create(context, nodeId, …, assignNodeIds: true)` sets the
explicit id and then `AssignNodeIds` overwrites it from the factory —
which silently moved `…SupervisionProcessFluid.Cavitation.Alarm` to
`…SupervisionProcessFluid.CavitationAlarm` and broke five alarm tests
until the guard was added.

**It has to be narrow.** A browse name is unique only among its
siblings. The HA override originally named every node the manager
minted, and the historian hangs an `HA Configuration` object off each
node it historizes — so the Counter's and the HistoryEvents' one both
landed on `ns=2;s=HA Configuration` and the strong active/passive
replicas refused to start. It is now limited to the nodes the redundant
client actually spells out.

## Merge with master

Merged `origin/master` (`0e600c0f0`). The one conflict was in
`HaSampleNodeManager.CreateAddressSpaceAsync`, where #4445 added
`AddFactoryAssignedNodes` next to the `HistoryEvents` object this branch
had converted. Both are kept, and the factory-assigned subtree stays
hand-built and unstaged on purpose: it exists to show what an identity
minted straight through `NodeIdFactory` looks like beside the sample's
named ones. It is attached once the folder has been staged, so those
identifiers survive untouched and the subtree is still registered as
part of the folder's. Its parent's identifier is `s=HighAvailability` on
both sides, so the ids it mints are unchanged from master.

## Not candidates

**`src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs`** is out for the
issue's own DI-device-builder reasoning. All three of its build paths
are reachable from runtime Method calls — `OnAddDataSetFolder` and
`OnRemoveDataSetFolder` → `RebuildConfigurationAddressSpaceAsync`,
`OnAddPushTarget` / `OnRemovePushTarget` →
`RebuildKeyPushTargetAddressSpaceAsync`, `OnGetSecurityGroup` /
`OnAddSecurityGroup` / `OnRemoveSecurityGroup` →
`RebuildSecurityGroupAddressSpaceAsync` — plus `ConfigurationChanged`.
Each removes its previous roots and rebuilds them, and `Add*` throws
`BadInvalidState` once `RegisterAuthoredNodesAsync` has run.
Independently of that, its parents (`PublishSubscribe`,
`PublishedDataSets`, `SecurityGroups`, `KeyPushTargets`) are owned by
the diagnostics node manager, so `AttachToParent` would reject them as
`BadNodeIdUnknown`.


**`src/Opc.Ua.OpenUsd.Server/Scene/UsdSceneMaterializer.Properties.cs`**
— the issue asked whether a builder is reachable at those call sites. It
is not. `MaterializeUsdStage` is a public, documented `ISystemContext`
extension (`docs/OpenUsd.md`, the package README) whose only input is
the context; it returns a detached subtree for the caller to register.
It already mints through `context.RequireNodeIdFactory()`, which is the
same factory the staged path uses, so there is nothing to gain and a
public signature to break.

## Verification

Run against the merged tree:

- `Opc.Ua.Di.Tests` — 425/425 pass (covers `PumpOpenUsdE2eTests`,
`PumpHostedReferenceTests`).
- `Opc.Ua.Redundancy.Samples.Tests` — 14/14 pass, including both
variants of master's new
`FactoryAssignedNodeIdsSurviveActiveReplicaFailureAsync` and
`StrongHistorianContinuationsSurviveActiveReplicaFailureAsync`.
- `Opc.Ua.WotCon.Samples.Tests` — 25 pass, 4 fail. The same 4 fail on a
clean baseline worktree
(`CompanionTypeDefinitionsMatchNativePumpServerAsync`,
`WotPumpInstanceMatchesNativePumpSubsetAsync`,
`RealSamplesAggregateSubscribeAndReplaceGenerationAsync`,
`RealSamplesRouteManagementAndConditionActionsToEachSourceAsync` — all
`BadConfigurationError` out of the aggregation client). Pre-existing,
unrelated to this change.
- All three sample projects build with 0 warnings and 0 errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
romanett
2026-09-10 14:11:55 +02:00
committed by GitHub
parent 0e600c0f0b
commit a05a360550
5 changed files with 368 additions and 387 deletions
@@ -36,6 +36,7 @@ using Opc.Ua.OpenUsd;
using Opc.Ua.OpenUsd.Server;
using Opc.Ua.Pumps;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.NodeManager;
namespace Pumps
@@ -94,8 +95,7 @@ namespace Pumps
/// needles that make each machine read as its own.
/// </para>
/// </remarks>
private async ValueTask MaterialisePlantAggregationAsync(
CancellationToken cancellationToken)
private void MaterialisePlantAggregation(INodeManagerBuilder builder)
{
if (m_plantStage == null)
{
@@ -131,15 +131,14 @@ namespace Pumps
assetReference: "@pump.usda@</Pump>",
componentTypeDefinition: pumpTypeId);
SystemContext.AssignInstanceChildNodeIds(plantRep);
await AddPredefinedNodeAsync(SystemContext, plantRep, cancellationToken)
.ConfigureAwait(false);
// Staging assigns the whole subtree its final NodeIds - the same
// pass AssignInstanceChildNodeIds used to run here - and leaves
// the registration to CreateAddressSpaceAsync.
builder.Add(plantRep, deviceSet.NodeId);
FolderState? registry = m_openUsdRoot?.Representations;
if (registry != null)
if (m_openUsdRoot?.Representations is FolderState registry)
{
registry.AddReference(ReferenceTypeIds.Organizes, false, plantRep.NodeId);
plantRep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId);
plantRep.RegisterInDiscovery(registry);
}
m_logger.MaterialisedPlantAggregation(m_twins.Count);
@@ -156,7 +155,12 @@ namespace Pumps
// connected server simulates and nothing else. A line pump is a static
// topology entry, not a machine anyone is driving; rendering it put
// phantom pumps in the twin that no client could account for.
private async ValueTask MaterialiseProductionLineAsync(CancellationToken cancellationToken)
// The subtree is staged on the builder rather than registered here: the
// manager's CreateAddressSpaceAsync registers everything staged during
// Configure and runs the reverse-reference pass over it, and staging
// mints the NodeIds through the same factory this method used to call
// by hand.
private void MaterialiseProductionLine(INodeManagerBuilder builder)
{
if (m_plantStage == null)
{
@@ -172,35 +176,18 @@ namespace Pumps
return;
}
var line = new BaseObjectState(deviceSet)
{
SymbolicName = "ProductionLine",
BrowseName = new QualifiedName("ProductionLine", ns),
DisplayName = new LocalizedText("ProductionLine"),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType
};
deviceSet.AddChild(line);
line.NodeId = SystemContext.NodeIdFactory.New(SystemContext, line);
BaseObjectState line = builder
.AddObject(new QualifiedName("ProductionLine", ns), deviceSet.NodeId)
.Node;
var pumps = new FolderState(line)
{
SymbolicName = "Pumps",
BrowseName = new QualifiedName("Pumps", ns),
DisplayName = new LocalizedText("Pumps"),
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType
};
line.AddChild(pumps);
pumps.NodeId = SystemContext.NodeIdFactory.New(SystemContext, pumps);
FolderState pumps = builder
.AddFolder(new QualifiedName("Pumps", ns), line.NodeId)
.Node;
m_linePumps = pumps;
// Two static aggregated entries (1..n baseline).
CreateAggregatedPump(pumps, "P-201", ns);
CreateAggregatedPump(pumps, "P-202", ns);
SystemContext.AssignInstanceChildNodeIds(line);
await AddPredefinedNodeAsync(SystemContext, line, cancellationToken).ConfigureAwait(false);
CreateAggregatedPump(builder, pumps.NodeId, "P-201", ns);
CreateAggregatedPump(builder, pumps.NodeId, "P-202", ns);
// Dynamic composition: emit model-change events on runtime add/remove.
ModelChangeEmissionEnabled = true;
@@ -217,18 +204,13 @@ namespace Pumps
// An aggregated line entry: a plain topology Object with no
// representation, because it is not a machine the server simulates.
private void CreateAggregatedPump(NodeState parent, string name, ushort ns)
private static void CreateAggregatedPump(
INodeManagerBuilder builder, NodeId parentId, string name, ushort ns)
{
var obj = new BaseObjectState(parent)
{
SymbolicName = name,
BrowseName = new QualifiedName(name, ns),
DisplayName = new LocalizedText(name),
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType
};
parent.AddChild(obj);
obj.NodeId = SystemContext.NodeIdFactory.New(SystemContext, obj);
BaseObjectState entry = builder
.AddObject(new QualifiedName(name, ns), parentId)
.Node;
entry.ReferenceTypeId = ReferenceTypeIds.Organizes;
}
// Dynamic demo (§5.13): repeatedly add a pump (emits a GeneralModelChange),
@@ -186,7 +186,7 @@ namespace Pumps
// predefined instances that Configure(builder) will wire.
// Mirrors the synchronous fluent Configure(builder) but
// runs first so the builder has typed nodes available.
await ConfigureInstancesAsync(cancellationToken)
await ConfigureInstancesAsync(builder, cancellationToken)
.ConfigureAwait(false);
// Configuration phase 2 (sync): wire fluent callbacks
@@ -223,6 +223,7 @@ namespace Pumps
/// directly.
/// </remarks>
private async ValueTask ConfigureInstancesAsync(
INodeManagerBuilder builder,
CancellationToken cancellationToken)
{
// OpenUSD facility first so the pump representation can reference the stage.
@@ -247,12 +248,12 @@ namespace Pumps
// Plant-level aggregation: composes one full-fidelity pump prim per
// configured pump, so the rendered scene scales with --pumps N.
await MaterialisePlantAggregationAsync(cancellationToken).ConfigureAwait(false);
MaterialisePlantAggregation(builder);
// Composition demo: a ProductionLine aggregating 1..n pumps (Many), with a
// dynamically added/removed pump (model-change events) and a cross-server
// component (federation). See OpenUsdComposition.cs.
await MaterialiseProductionLineAsync(cancellationToken).ConfigureAwait(false);
MaterialiseProductionLine(builder);
}
/// <summary>
@@ -37,6 +37,7 @@ using Opc.Ua;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.Historian;
using Opc.Ua.Server.Hosting;
@@ -147,9 +148,9 @@ namespace RedundantServer
}
/// <summary>
/// Minimal <see cref="AsyncCustomNodeManager"/> address space that participates in distributed replication.
/// Minimal <see cref="FluentNodeManagerBase"/> address space that participates in distributed replication.
/// </summary>
public sealed class HaSampleNodeManager : AsyncCustomNodeManager
public sealed class HaSampleNodeManager : FluentNodeManagerBase
{
private readonly ILeaderElection m_leaderElection;
private readonly HaSampleReplicaInfo m_replicaInfo;
@@ -158,8 +159,10 @@ namespace RedundantServer
private readonly CancellationTokenSource m_simulationCts = new();
private readonly Lock m_updateLock = new();
private static readonly TimeSpan s_valueFreshness = TimeSpan.FromSeconds(10);
private BaseDataVariableState? m_counter;
private BaseDataVariableState? m_activeReplica;
private const string kSampleFolderName = "HighAvailability";
private FolderState? m_folder;
private BaseVariableState? m_counter;
private BaseVariableState? m_activeReplica;
private BaseObjectState? m_historyEvents;
private Task? m_simulationTask;
private int m_counterValue;
@@ -193,22 +196,85 @@ namespace RedundantServer
m_historian = historian;
}
/// <summary>
/// Mints the browse name as the identifier, for the handful of nodes
/// this sample publishes by name.
/// </summary>
/// <remarks>
/// <para>
/// Those identifiers are part of the sample's contract - the redundant
/// client addresses <c>ns=N;s=Counter</c> directly - so they cannot be
/// left to the derived form the default factory mints.
/// </para>
/// <para>
/// Every other node keeps that derived form, and the narrowness
/// matters: a browse name is only unique among its siblings, and the
/// historian hangs an <c>HA Configuration</c> object off each node it
/// historizes, so a blanket rule would hand the Counter's and the
/// HistoryEvents' one the same identifier. An identifier a caller
/// already chose here is kept, on the default factory's own terms.
/// </para>
/// </remarks>
public override NodeId New(ISystemContext context, NodeState node)
{
if (node == null)
{
return base.New(context, node!);
}
ushort namespaceIndex = NamespaceIndexes[0];
if (!node.NodeId.IsNull &&
(node.NodeId.NamespaceIndex == namespaceIndex ||
node is not BaseInstanceState { Parent: not null }))
{
return node.NodeId;
}
return IsPublishedByName(node)
? new NodeId(node.BrowseName.Name!, namespaceIndex)
: base.New(context, node);
}
/// <summary>
/// Whether <paramref name="node"/> is the sample folder or one of the
/// nodes directly beneath it - the ones a client spells out.
/// </summary>
/// <remarks>
/// The folder reaches this before it has been staged, so it has no
/// parent yet and is recognised by its browse name instead.
/// </remarks>
private bool IsPublishedByName(NodeState node)
{
if (string.IsNullOrEmpty(node.BrowseName.Name))
{
return false;
}
NodeState? parent = (node as BaseInstanceState)?.Parent;
return parent == null
? node.BrowseName.Name == kSampleFolderName
: ReferenceEquals(parent, m_folder);
}
/// <inheritdoc/>
public override async ValueTask CreateAddressSpaceAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList<IReference>? references))
{
externalReferences[ObjectIds.ObjectsFolder] = references = [];
}
NodeManagerBuilder builder = CreateFluentBuilder(NamespaceIndexes[0]);
ushort namespaceIndex = NamespaceIndexes[0];
FolderState folder = CreateFolder(null, namespaceIndex, "HighAvailability", "High Availability");
folder.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, folder.NodeId));
// Unparented, so the staged folder organises itself under the
// Objects folder; the manager used to add that reference and its
// externalReferences counterpart by hand.
INodeBuilder<FolderState> folder = builder.AddFolder(kSampleFolderName);
folder.Node.DisplayName = new LocalizedText("en", "High Availability");
NodeId folderId = folder.Node.NodeId;
m_counter = CreateVariable(folder, namespaceIndex, "Counter", DataTypeIds.Int32, Variant.From(0));
// New() names the folder's direct children after their browse
// name, so it has to know which node the folder is.
m_folder = folder.Node;
m_counter = AddSampleVariable<int>(builder, folderId, "Counter", Variant.From(0));
if (m_valueCache != null)
{
// Opt the Counter's read/write callbacks into the distributed
@@ -224,27 +290,24 @@ namespace RedundantServer
_ => new ValueTask<DataValue>(ReadLocalCounter()));
}
m_activeReplica = CreateVariable(
folder,
namespaceIndex,
m_activeReplica = AddSampleVariable<string>(
builder,
folderId,
"ActiveReplica",
DataTypeIds.String,
Variant.From("unknown"));
m_historyEvents = new BaseObjectState(folder)
{
SymbolicName = "HistoryEvents",
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.BaseObjectType,
NodeId = new NodeId("HistoryEvents", namespaceIndex),
BrowseName = new QualifiedName("HistoryEvents", namespaceIndex),
DisplayName = new LocalizedText("en", "History Events"),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.SubscribeToEvents
};
folder.AddChild(m_historyEvents);
AddFactoryAssignedNodes(folder, namespaceIndex);
m_historyEvents = builder.AddObject("HistoryEvents", folderId).Node;
m_historyEvents.ReferenceTypeId = ReferenceTypeIds.Organizes;
m_historyEvents.DisplayName = new LocalizedText("en", "History Events");
m_historyEvents.EventNotifier = EventNotifiers.SubscribeToEvents;
// Deliberately not staged: this subtree exists to show what a
// factory-assigned identity looks like next to the named ones
// above, so it keeps minting through the NodeIdFactory itself
// rather than through New. Attaching it once the folder has been
// staged leaves those identifiers untouched, and the subtree is
// still registered as part of the folder's.
AddFactoryAssignedNodes(folder.Node, NamespaceIndexes[0]);
if (m_historian != null)
{
@@ -268,7 +331,13 @@ namespace RedundantServer
cancellationToken: cancellationToken).ConfigureAwait(false);
}
await AddPredefinedNodeAsync(SystemContext, folder, cancellationToken).ConfigureAwait(false);
// Registering the staged subtree and re-running the reverse
// reference pass is what publishes the folder's inverse Organizes
// reference into externalReferences[ObjectsFolder].
await RegisterAuthoredNodesAsync(builder, cancellationToken).ConfigureAwait(false);
await CompleteConfigureAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
await SealConfigurationAsync(builder, cancellationToken).ConfigureAwait(false);
}
private void AddFactoryAssignedNodes(FolderState parent, ushort namespaceIndex)
@@ -331,53 +400,36 @@ namespace RedundantServer
return m_historian ?? base.GetHistorianProvider(node);
}
private static FolderState CreateFolder(NodeState? parent, ushort namespaceIndex, string path, string name)
{
var folder = new FolderState(parent)
{
SymbolicName = name,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId(path, namespaceIndex),
BrowseName = new QualifiedName(path, namespaceIndex),
DisplayName = new LocalizedText("en", name),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.None
};
parent?.AddChild(folder);
return folder;
}
private static BaseDataVariableState CreateVariable(
NodeState parent,
ushort namespaceIndex,
/// <summary>
/// Stages one writable sample variable under the sample folder.
/// </summary>
/// <typeparam name="TValue">
/// CLR type the variable carries; the staged node takes its DataType
/// and ValueRank from it.
/// </typeparam>
/// <param name="builder">The fluent builder staging the subtree.</param>
/// <param name="parentId">The folder the variable hangs off.</param>
/// <param name="name">Browse name of the variable.</param>
/// <param name="initialValue">The value a client reads before the
/// simulation has produced one.</param>
private static BaseVariableState AddSampleVariable<TValue>(
NodeManagerBuilder builder,
NodeId parentId,
string name,
NodeId dataType,
Variant initialValue)
{
var variable = new BaseDataVariableState(parent)
{
SymbolicName = name,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(name, namespaceIndex),
BrowseName = new QualifiedName(name, namespaceIndex),
DisplayName = new LocalizedText("en", name),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
DataType = dataType,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentReadOrWrite,
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
Historizing = false,
Value = initialValue,
StatusCode = StatusCodes.Good,
Timestamp = DateTime.UtcNow
};
BaseVariableState variable = builder
.AddVariable<TValue>(name, parentId)
.Writable()
.Node;
parent.AddChild(variable);
// The sample organises its variables rather than componentising
// them, which is the shape the redundant client browses.
variable.ReferenceTypeId = ReferenceTypeIds.Organizes;
variable.DisplayName = new LocalizedText("en", name);
variable.Value = initialValue;
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = DateTime.UtcNow;
return variable;
}
@@ -446,7 +498,7 @@ namespace RedundantServer
private async Task UpdateCounterAsync(CancellationToken cancellationToken)
{
BaseDataVariableState? counter = m_counter;
BaseVariableState? counter = m_counter;
if (counter == null)
{
return;
@@ -519,7 +571,7 @@ namespace RedundantServer
}
private async Task<bool> ArchiveCounterAsync(
BaseDataVariableState counter,
BaseVariableState counter,
DataValue sample,
CancellationToken cancellationToken)
{
@@ -572,7 +624,7 @@ namespace RedundantServer
private async Task SeedCounterFromCacheAsync(CancellationToken cancellationToken)
{
BaseDataVariableState? counter = m_counter;
BaseVariableState? counter = m_counter;
if (m_valueCache == null || counter == null)
{
return;
@@ -611,7 +663,7 @@ namespace RedundantServer
private void UpdateActiveReplica(string value)
{
BaseDataVariableState? activeReplica = m_activeReplica;
BaseVariableState? activeReplica = m_activeReplica;
if (activeReplica == null)
{
return;
+197 -254
View File
@@ -33,6 +33,7 @@ using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
namespace FlatTagServer
{
@@ -75,7 +76,7 @@ namespace FlatTagServer
/// <summary>
/// Minimal async address space exposing one half of the aggregate Pump tags.
/// </summary>
public sealed class FlatTagNodeManager : AsyncCustomNodeManager
public sealed class FlatTagNodeManager : FluentNodeManagerBase
{
/// <summary>
/// Initializes the node manager.
@@ -88,6 +89,53 @@ namespace FlatTagServer
m_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Mints the dotted tag paths this sample is addressed by.
/// </summary>
/// <remarks>
/// The identifiers are part of the sample's contract rather than an
/// implementation detail: the aggregating client's Thing Descriptions
/// spell out <c>s=Pump1.Identification.Manufacturer</c>, so a staged
/// node keeps its browse path as its identifier instead of taking the
/// hashed one the default factory would mint.
/// </remarks>
public override NodeId New(ISystemContext context, NodeState node)
{
ushort namespaceIndex = NamespaceIndexes[0];
// An identifier the caller has already chosen here is kept, on the
// same terms as the default factory: the supervision signals name
// their tag and their condition explicitly, and NodeState.Create
// runs its assignment pass over a subtree that already carries
// them. A staged node arrives with its NodeId cleared, so it falls
// through to the browse path below.
if (node is not null &&
!node.NodeId.IsNull &&
(node.NodeId.NamespaceIndex == namespaceIndex ||
node is not BaseInstanceState { Parent: not null }))
{
return node.NodeId;
}
string? browseName = node?.BrowseName.Name;
if (string.IsNullOrEmpty(browseName))
{
return base.New(context, node!);
}
if (node is BaseInstanceState instance &&
instance.Parent is NodeState parent &&
parent.NodeId.NamespaceIndex == namespaceIndex &&
parent.NodeId.IdType == IdType.String)
{
return new NodeId(
parent.NodeId.IdentifierAsString + "." + browseName,
namespaceIndex);
}
return new NodeId(browseName!, namespaceIndex);
}
/// <inheritdoc/>
public override async ValueTask CreateAddressSpaceAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
@@ -98,161 +146,97 @@ namespace FlatTagServer
throw new ArgumentNullException(nameof(externalReferences));
}
if (!externalReferences.TryGetValue(
ObjectIds.ObjectsFolder,
out IList<IReference>? references))
{
externalReferences[ObjectIds.ObjectsFolder] = references = [];
}
NodeManagerBuilder builder = CreateFluentBuilder(NamespaceIndexes[0]);
await AddPumpAsync(builder, "Pump1", m_options.Values, cancellationToken)
.ConfigureAwait(false);
await AddPumpAsync(builder, "Pump2", m_options.Pump2Values, cancellationToken)
.ConfigureAwait(false);
ushort namespaceIndex = NamespaceIndexes[0];
await AddPumpAsync(
references,
namespaceIndex,
"Pump1",
m_options.Values,
cancellationToken).ConfigureAwait(false);
await AddPumpAsync(
references,
namespaceIndex,
"Pump2",
m_options.Pump2Values,
cancellationToken).ConfigureAwait(false);
// Registering the staged pumps and then re-running the reverse
// reference pass is what puts each pump's inverse Organizes
// reference into externalReferences[ObjectsFolder]; the manager used
// to maintain that entry by hand.
await RegisterAuthoredNodesAsync(builder, cancellationToken).ConfigureAwait(false);
await CompleteConfigureAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
await SealConfigurationAsync(builder, cancellationToken).ConfigureAwait(false);
}
private async ValueTask AddPumpAsync(
IList<IReference> externalReferences,
ushort namespaceIndex,
string pumpNodeId,
INodeManagerBuilder builder,
string pumpName,
FlatTagValues values,
CancellationToken cancellationToken)
{
BaseObjectState pump = CreateObject(
null,
namespaceIndex,
pumpNodeId,
pumpNodeId,
ReferenceTypeIds.Organizes);
pump.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
externalReferences.Add(new NodeStateReference(
ReferenceTypeIds.Organizes,
false,
pump.NodeId));
// Unparented, so the staged node organises itself under the Objects
// folder and its NodeId comes out as the bare pump name.
INodeBuilder<BaseObjectState> pump = AddGroup(builder, pumpName);
NodeId pumpId = pump.Node.NodeId;
BaseObjectState identification = CreateObject(
pump, namespaceIndex, pumpNodeId + ".Identification", "Identification");
CreateVariable(
identification, namespaceIndex, pumpNodeId + ".Identification.Manufacturer", "Manufacturer",
DataTypeIds.LocalizedText, Variant.From(new LocalizedText(values.Manufacturer)), property: true);
CreateVariable(
identification, namespaceIndex, pumpNodeId + ".Identification.SerialNumber", "SerialNumber",
DataTypeIds.String, Variant.From(values.SerialNumber), property: true);
CreateVariable(
identification, namespaceIndex, pumpNodeId + ".Identification.ProductInstanceUri", "ProductInstanceUri",
DataTypeIds.String, Variant.From(values.ProductInstanceUri), property: true);
NodeId identification = AddGroup(builder, "Identification", pumpId).Node.NodeId;
AddTag<LocalizedText>(
builder, identification, "Manufacturer",
Variant.From(new LocalizedText(values.Manufacturer)), property: true);
AddTag<string>(
builder, identification, "SerialNumber",
Variant.From(values.SerialNumber), property: true);
AddTag<string>(
builder, identification, "ProductInstanceUri",
Variant.From(values.ProductInstanceUri), property: true);
BaseObjectState operational = CreateObject(
pump,
namespaceIndex,
pumpNodeId + ".Operational",
"Operational");
BaseObjectState measurements = CreateObject(
operational,
namespaceIndex,
pumpNodeId + ".Operational.Measurements",
"Measurements");
BaseObjectState events = CreateObject(
pump,
namespaceIndex,
pumpNodeId + ".Events",
"Events");
NodeId operational = AddGroup(builder, "Operational", pumpId).Node.NodeId;
NodeId measurements = AddGroup(builder, "Measurements", operational).Node.NodeId;
INodeBuilder<BaseObjectState> events = AddGroup(builder, "Events", pumpId);
// The pump is the notifier an aggregating server subscribes to. Its
// conditions live under the supervision Objects below it, and OPC
// 10000-3 only delivers their events to a client that can reach a
// notifier, so the pump both carries the bit and is registered as a
// root notifier with the server.
pump.EventNotifier = EventNotifiers.SubscribeToEvents;
events.EventNotifier = EventNotifiers.SubscribeToEvents;
await AddRootNotifierAsync(pump, cancellationToken).ConfigureAwait(false);
pump.Node.EventNotifier = EventNotifiers.SubscribeToEvents;
events.Node.EventNotifier = EventNotifiers.SubscribeToEvents;
await AddRootNotifierAsync(pump.Node, cancellationToken).ConfigureAwait(false);
var signals = new List<SupervisionSignal>();
AddManagementMethods(namespaceIndex, pump, pumpNodeId, signals);
AddManagementMethods(builder, pumpId, signals);
if (m_options.SourceNamespaceUri == FlatTagServerOptions.SourceANamespaceUri)
{
AddSourceAVariables(
namespaceIndex,
pumpNodeId,
measurements,
events,
values,
signals);
AddSourceAVariables(builder, measurements, events.Node.NodeId, values, signals);
}
else
{
AddSourceBVariables(
namespaceIndex,
pumpNodeId,
measurements,
events,
values,
signals);
AddSourceBVariables(builder, measurements, events.Node.NodeId, values, signals);
}
await AddPredefinedNodeAsync(SystemContext, pump, cancellationToken)
.ConfigureAwait(false);
}
private void AddSourceAVariables(
ushort namespaceIndex,
string pumpNodeId,
BaseObjectState measurements,
BaseObjectState events,
INodeManagerBuilder builder,
NodeId measurements,
NodeId events,
FlatTagValues values,
List<SupervisionSignal> signals)
{
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.DifferentialPressure",
"DifferentialPressure",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "DifferentialPressure",
Variant.From(values.DifferentialPressure));
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.FluidTemperature",
"FluidTemperature",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "FluidTemperature",
Variant.From(values.FluidTemperature));
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.MassFlow",
"MassFlow",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "MassFlow",
Variant.From(values.MassFlow));
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.Level",
"Level",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "Level",
Variant.From(values.Level));
BaseObjectState supervision = CreateObject(
events,
namespaceIndex,
pumpNodeId + ".Events.SupervisionProcessFluid",
"SupervisionProcessFluid");
BaseObjectState supervision = AddGroup(
builder, "SupervisionProcessFluid", events).Node;
supervision.EventNotifier = EventNotifiers.SubscribeToEvents;
signals.Add(new SupervisionSignal(
SystemContext,
Server.Telemetry,
supervision,
namespaceIndex,
pumpNodeId + ".Events.SupervisionProcessFluid.Cavitation",
"Cavitation",
"CavitationAlarm",
severity: 700,
@@ -260,54 +244,32 @@ namespace FlatTagServer
}
private void AddSourceBVariables(
ushort namespaceIndex,
string pumpNodeId,
BaseObjectState measurements,
BaseObjectState events,
INodeManagerBuilder builder,
NodeId measurements,
NodeId events,
FlatTagValues values,
List<SupervisionSignal> signals)
{
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.BearingTemperature",
"BearingTemperature",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "BearingTemperature",
Variant.From(values.BearingTemperature));
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.PumpPowerInput",
"PumpPowerInput",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "PumpPowerInput",
Variant.From(values.PumpPowerInput));
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.PumpEfficiency",
"PumpEfficiency",
DataTypeIds.Double,
AddTag<double>(
builder, measurements, "PumpEfficiency",
Variant.From(values.PumpEfficiency));
CreateVariable(
measurements,
namespaceIndex,
pumpNodeId + ".Operational.Measurements.NumberOfStarts",
"NumberOfStarts",
DataTypeIds.UInt32,
AddTag<uint>(
builder, measurements, "NumberOfStarts",
Variant.From(values.NumberOfStarts));
BaseObjectState supervision = CreateObject(
events,
namespaceIndex,
pumpNodeId + ".Events.SupervisionPumpOperation",
"SupervisionPumpOperation");
BaseObjectState supervision = AddGroup(
builder, "SupervisionPumpOperation", events).Node;
supervision.EventNotifier = EventNotifiers.SubscribeToEvents;
signals.Add(new SupervisionSignal(
SystemContext,
Server.Telemetry,
supervision,
namespaceIndex,
pumpNodeId + ".Events.SupervisionPumpOperation.MotorOverheat",
"MotorOverheat",
"MotorOverheatAlarm",
severity: 800,
@@ -320,54 +282,34 @@ namespace FlatTagServer
/// also what returns a tripped supervision signal to normal.
/// </summary>
private static void AddManagementMethods(
ushort namespaceIndex,
BaseObjectState pump,
string pumpNodeId,
INodeManagerBuilder builder,
NodeId pumpId,
List<SupervisionSignal> signals)
{
var running = new BaseDataVariableState(pump)
{
SymbolicName = "Running",
ReferenceTypeId = ReferenceTypeIds.HasComponent,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(pumpNodeId + ".Running", namespaceIndex),
BrowseName = new QualifiedName("Running", namespaceIndex),
DisplayName = new LocalizedText("en", "Running"),
DataType = DataTypeIds.Boolean,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
Value = Variant.From(true),
StatusCode = StatusCodes.Good,
Timestamp = DateTime.UtcNow
};
pump.AddChild(running);
BaseVariableState running = AddTag<bool>(
builder, pumpId, "Running", Variant.From(true), readBack: false);
CreateMethod(
pump,
namespaceIndex,
pumpNodeId + ".Start",
AddCommand(
builder,
pumpId,
"Start",
(context, _, _, _, _, _) =>
{
SetRunning(context, running, value: true);
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
CreateMethod(
pump,
namespaceIndex,
pumpNodeId + ".Stop",
AddCommand(
builder,
pumpId,
"Stop",
(context, _, _, _, _, _) =>
{
SetRunning(context, running, value: false);
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
CreateMethod(
pump,
namespaceIndex,
pumpNodeId + ".Reset",
AddCommand(
builder,
pumpId,
"Reset",
(context, _, _, _, _, _) =>
{
@@ -384,7 +326,7 @@ namespace FlatTagServer
private static void SetRunning(
ISystemContext context,
BaseDataVariableState running,
BaseVariableState running,
bool value)
{
running.Value = Variant.From(value);
@@ -392,84 +334,85 @@ namespace FlatTagServer
running.ClearChangeMasks(context, includeChildren: false);
}
private static void CreateMethod(
NodeState parent,
ushort namespaceIndex,
string nodeId,
/// <summary>
/// Stages one of the grouping Objects a pump is browsed through.
/// </summary>
/// <remarks>
/// Only the display name is set here: the staged node already carries
/// the browse name, the type definition and - through
/// <see cref="New"/> - the dotted NodeId this sample publishes. The
/// sample tags its display names with a locale, which the fluent
/// default does not.
/// </remarks>
private static INodeBuilder<BaseObjectState> AddGroup(
INodeManagerBuilder builder,
string browseName,
GenericMethodCalledEventHandler2Async onCall)
NodeId parentId = default)
{
var method = new MethodState(parent)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
NodeId = new NodeId(nodeId, namespaceIndex),
BrowseName = new QualifiedName(browseName, namespaceIndex),
DisplayName = new LocalizedText("en", browseName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
Executable = true,
UserExecutable = true,
OnCallMethod2Async = onCall
};
parent.AddChild(method);
}
private static BaseObjectState CreateObject(
NodeState? parent,
ushort namespaceIndex,
string nodeId,
string browseName,
NodeId referenceTypeId = default)
{
var node = new BaseObjectState(parent)
{
SymbolicName = browseName,
ReferenceTypeId = referenceTypeId.IsNull
? ReferenceTypeIds.HasComponent
: referenceTypeId,
TypeDefinitionId = ObjectTypeIds.BaseObjectType,
NodeId = new NodeId(nodeId, namespaceIndex),
BrowseName = new QualifiedName(browseName, namespaceIndex),
DisplayName = new LocalizedText("en", browseName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.None
};
parent?.AddChild(node);
INodeBuilder<BaseObjectState> node = builder.AddObject(browseName, parentId);
node.Node.DisplayName = new LocalizedText("en", browseName);
return node;
}
private static void CreateVariable(
NodeState parent,
ushort namespaceIndex,
string nodeId,
/// <summary>
/// Stages one flat tag holding a constant value.
/// </summary>
/// <typeparam name="TValue">
/// CLR type the tag carries; the staged variable takes its DataType
/// and ValueRank from it.
/// </typeparam>
/// <param name="builder">The fluent builder staging the pump.</param>
/// <param name="parentId">The Object the tag hangs off.</param>
/// <param name="browseName">Browse name of the tag.</param>
/// <param name="value">The value the tag reports.</param>
/// <param name="property">
/// Whether the tag is a Property rather than a data variable.
/// </param>
/// <param name="readBack">
/// Whether reads are answered from <paramref name="value"/> through an
/// async callback. Tags the server itself drives - the pump's
/// <c>Running</c> flag - pass <c>false</c> so a write from the
/// simulation is what a client sees.
/// </param>
private static BaseVariableState AddTag<TValue>(
INodeManagerBuilder builder,
NodeId parentId,
string browseName,
NodeId dataType,
Variant value,
bool property = false)
bool property = false,
bool readBack = true)
{
var variable = new BaseDataVariableState(parent)
IVariableBuilder<TValue> tag = builder.AddVariable<TValue>(browseName, parentId);
BaseVariableState node = tag.Node;
node.DisplayName = new LocalizedText("en", browseName);
if (property)
{
SymbolicName = browseName,
ReferenceTypeId = property ? ReferenceTypeIds.HasProperty : ReferenceTypeIds.HasComponent,
TypeDefinitionId = property ? VariableTypeIds.PropertyType : VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(nodeId, namespaceIndex),
BrowseName = new QualifiedName(browseName, namespaceIndex),
DisplayName = new LocalizedText("en", browseName),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
DataType = dataType,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
Value = value,
StatusCode = StatusCodes.Good,
Timestamp = DateTime.UtcNow,
OnSimpleReadValueAsync = (_, _, ct) => ReadValueAsync(value, ct)
};
parent.AddChild(variable);
node.ReferenceTypeId = ReferenceTypeIds.HasProperty;
node.TypeDefinitionId = VariableTypeIds.PropertyType;
}
node.Value = value;
node.StatusCode = StatusCodes.Good;
node.Timestamp = DateTime.UtcNow;
if (readBack)
{
tag.OnRead((_, _, ct) => ReadValueAsync(value, ct));
}
return node;
}
/// <summary>
/// Stages one of the argument-less Methods an operator manages the
/// pump with.
/// </summary>
private static void AddCommand(
INodeManagerBuilder builder,
NodeId parentId,
string browseName,
GenericMethodCalledEventHandler2Async onCall)
{
INodeBuilder<MethodState> method = builder.AddMethod(browseName, parentId);
method.Node.DisplayName = new LocalizedText("en", browseName);
method.OnCall(onCall);
}
private static async ValueTask<AttributeSimpleReadResult> ReadValueAsync(
@@ -60,9 +60,11 @@ namespace FlatTagServer
/// </summary>
/// <param name="context">The system context used to create the nodes.</param>
/// <param name="telemetry">The telemetry context of the owning server.</param>
/// <param name="parent">The supervision Object that owns the signal.</param>
/// <param name="namespaceIndex">The namespace the nodes belong to.</param>
/// <param name="tagPath">The node identifier of the boolean tag.</param>
/// <param name="parent">
/// The supervision Object that owns the signal. Both nodes take their
/// namespace and the leading part of their identifier from it, so the
/// signal follows wherever the supervision Object was staged.
/// </param>
/// <param name="name">The browse name of the boolean tag.</param>
/// <param name="conditionName">The browse name of the alarm condition.</param>
/// <param name="severity">The severity reported while the alarm is active.</param>
@@ -74,8 +76,6 @@ namespace FlatTagServer
ISystemContext context,
ITelemetryContext telemetry,
BaseObjectState parent,
ushort namespaceIndex,
string tagPath,
string name,
string conditionName,
ushort severity,
@@ -86,6 +86,9 @@ namespace FlatTagServer
throw new ArgumentNullException(nameof(parent));
}
ushort namespaceIndex = parent.NodeId.NamespaceIndex;
string tagPath = parent.NodeId.IdentifierAsString + "." + name;
m_severity = severity;
m_active = initiallyActive;