From a05a360550a49bd732732fec72291bb749e491ca Mon Sep 17 00:00:00 2001 From: romanett Date: Thu, 10 Sep 2026 14:11:55 +0200 Subject: [PATCH] Adopt the fluent builder's node-creation surface in the remaining hand-rolled node managers (#4460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` / `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 --- .../OpenUsdComposition.cs | 74 ++- .../PumpNodeManager.cs | 7 +- .../RedundantServer/HaSampleNodeManager.cs | 210 +++++--- .../FlatTagServer/FlatTagNodeManager.cs | 451 ++++++++---------- .../WotCon/FlatTagServer/SupervisionSignal.cs | 13 +- 5 files changed, 368 insertions(+), 387 deletions(-) diff --git a/samples/DI/PumpDeviceIntegrationServer/OpenUsdComposition.cs b/samples/DI/PumpDeviceIntegrationServer/OpenUsdComposition.cs index 7aad5bec11..c1c30cf8c5 100644 --- a/samples/DI/PumpDeviceIntegrationServer/OpenUsdComposition.cs +++ b/samples/DI/PumpDeviceIntegrationServer/OpenUsdComposition.cs @@ -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. /// /// - private async ValueTask MaterialisePlantAggregationAsync( - CancellationToken cancellationToken) + private void MaterialisePlantAggregation(INodeManagerBuilder builder) { if (m_plantStage == null) { @@ -131,15 +131,14 @@ namespace Pumps assetReference: "@pump.usda@", 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), diff --git a/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs b/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs index 6bd91e96e4..f00a5902e4 100644 --- a/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs +++ b/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs @@ -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. /// 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); } /// diff --git a/samples/Redundancy/RedundantServer/HaSampleNodeManager.cs b/samples/Redundancy/RedundantServer/HaSampleNodeManager.cs index 105efc9d37..4ce5bbcc4d 100644 --- a/samples/Redundancy/RedundantServer/HaSampleNodeManager.cs +++ b/samples/Redundancy/RedundantServer/HaSampleNodeManager.cs @@ -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 } /// - /// Minimal address space that participates in distributed replication. + /// Minimal address space that participates in distributed replication. /// - 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; } + /// + /// Mints the browse name as the identifier, for the handful of nodes + /// this sample publishes by name. + /// + /// + /// + /// Those identifiers are part of the sample's contract - the redundant + /// client addresses ns=N;s=Counter directly - so they cannot be + /// left to the derived form the default factory mints. + /// + /// + /// 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 HA Configuration 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. + /// + /// + 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); + } + + /// + /// Whether is the sample folder or one of the + /// nodes directly beneath it - the ones a client spells out. + /// + /// + /// The folder reaches this before it has been staged, so it has no + /// parent yet and is recognised by its browse name instead. + /// + 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); + } + /// public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, CancellationToken cancellationToken = default) { - if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList? 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 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(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(ReadLocalCounter())); } - m_activeReplica = CreateVariable( - folder, - namespaceIndex, + m_activeReplica = AddSampleVariable( + 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, + /// + /// Stages one writable sample variable under the sample folder. + /// + /// + /// CLR type the variable carries; the staged node takes its DataType + /// and ValueRank from it. + /// + /// The fluent builder staging the subtree. + /// The folder the variable hangs off. + /// Browse name of the variable. + /// The value a client reads before the + /// simulation has produced one. + private static BaseVariableState AddSampleVariable( + 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(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 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; diff --git a/samples/WotCon/FlatTagServer/FlatTagNodeManager.cs b/samples/WotCon/FlatTagServer/FlatTagNodeManager.cs index 242560b707..9056fae708 100644 --- a/samples/WotCon/FlatTagServer/FlatTagNodeManager.cs +++ b/samples/WotCon/FlatTagServer/FlatTagNodeManager.cs @@ -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 /// /// Minimal async address space exposing one half of the aggregate Pump tags. /// - public sealed class FlatTagNodeManager : AsyncCustomNodeManager + public sealed class FlatTagNodeManager : FluentNodeManagerBase { /// /// Initializes the node manager. @@ -88,6 +89,53 @@ namespace FlatTagServer m_options = options ?? throw new ArgumentNullException(nameof(options)); } + /// + /// Mints the dotted tag paths this sample is addressed by. + /// + /// + /// The identifiers are part of the sample's contract rather than an + /// implementation detail: the aggregating client's Thing Descriptions + /// spell out s=Pump1.Identification.Manufacturer, so a staged + /// node keeps its browse path as its identifier instead of taking the + /// hashed one the default factory would mint. + /// + 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); + } + /// public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, @@ -98,161 +146,97 @@ namespace FlatTagServer throw new ArgumentNullException(nameof(externalReferences)); } - if (!externalReferences.TryGetValue( - ObjectIds.ObjectsFolder, - out IList? 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 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 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( + builder, identification, "Manufacturer", + Variant.From(new LocalizedText(values.Manufacturer)), property: true); + AddTag( + builder, identification, "SerialNumber", + Variant.From(values.SerialNumber), property: true); + AddTag( + 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 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(); - 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 signals) { - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.DifferentialPressure", - "DifferentialPressure", - DataTypeIds.Double, + AddTag( + builder, measurements, "DifferentialPressure", Variant.From(values.DifferentialPressure)); - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.FluidTemperature", - "FluidTemperature", - DataTypeIds.Double, + AddTag( + builder, measurements, "FluidTemperature", Variant.From(values.FluidTemperature)); - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.MassFlow", - "MassFlow", - DataTypeIds.Double, + AddTag( + builder, measurements, "MassFlow", Variant.From(values.MassFlow)); - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.Level", - "Level", - DataTypeIds.Double, + AddTag( + 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 signals) { - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.BearingTemperature", - "BearingTemperature", - DataTypeIds.Double, + AddTag( + builder, measurements, "BearingTemperature", Variant.From(values.BearingTemperature)); - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.PumpPowerInput", - "PumpPowerInput", - DataTypeIds.Double, + AddTag( + builder, measurements, "PumpPowerInput", Variant.From(values.PumpPowerInput)); - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.PumpEfficiency", - "PumpEfficiency", - DataTypeIds.Double, + AddTag( + builder, measurements, "PumpEfficiency", Variant.From(values.PumpEfficiency)); - CreateVariable( - measurements, - namespaceIndex, - pumpNodeId + ".Operational.Measurements.NumberOfStarts", - "NumberOfStarts", - DataTypeIds.UInt32, + AddTag( + 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. /// private static void AddManagementMethods( - ushort namespaceIndex, - BaseObjectState pump, - string pumpNodeId, + INodeManagerBuilder builder, + NodeId pumpId, List 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( + 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.Good); }); - CreateMethod( - pump, - namespaceIndex, - pumpNodeId + ".Stop", + AddCommand( + builder, + pumpId, "Stop", (context, _, _, _, _, _) => { SetRunning(context, running, value: false); return new ValueTask(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, + /// + /// Stages one of the grouping Objects a pump is browsed through. + /// + /// + /// Only the display name is set here: the staged node already carries + /// the browse name, the type definition and - through + /// - the dotted NodeId this sample publishes. The + /// sample tags its display names with a locale, which the fluent + /// default does not. + /// + private static INodeBuilder 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 node = builder.AddObject(browseName, parentId); + node.Node.DisplayName = new LocalizedText("en", browseName); return node; } - private static void CreateVariable( - NodeState parent, - ushort namespaceIndex, - string nodeId, + /// + /// Stages one flat tag holding a constant value. + /// + /// + /// CLR type the tag carries; the staged variable takes its DataType + /// and ValueRank from it. + /// + /// The fluent builder staging the pump. + /// The Object the tag hangs off. + /// Browse name of the tag. + /// The value the tag reports. + /// + /// Whether the tag is a Property rather than a data variable. + /// + /// + /// Whether reads are answered from through an + /// async callback. Tags the server itself drives - the pump's + /// Running flag - pass false so a write from the + /// simulation is what a client sees. + /// + private static BaseVariableState AddTag( + 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 tag = builder.AddVariable(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; + } + + /// + /// Stages one of the argument-less Methods an operator manages the + /// pump with. + /// + private static void AddCommand( + INodeManagerBuilder builder, + NodeId parentId, + string browseName, + GenericMethodCalledEventHandler2Async onCall) + { + INodeBuilder method = builder.AddMethod(browseName, parentId); + method.Node.DisplayName = new LocalizedText("en", browseName); + method.OnCall(onCall); } private static async ValueTask ReadValueAsync( diff --git a/samples/WotCon/FlatTagServer/SupervisionSignal.cs b/samples/WotCon/FlatTagServer/SupervisionSignal.cs index 69df0bf607..be1a8ed11d 100644 --- a/samples/WotCon/FlatTagServer/SupervisionSignal.cs +++ b/samples/WotCon/FlatTagServer/SupervisionSignal.cs @@ -60,9 +60,11 @@ namespace FlatTagServer /// /// The system context used to create the nodes. /// The telemetry context of the owning server. - /// The supervision Object that owns the signal. - /// The namespace the nodes belong to. - /// The node identifier of the boolean tag. + /// + /// 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. + /// /// The browse name of the boolean tag. /// The browse name of the alarm condition. /// The severity reported while the alarm is active. @@ -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;