Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions docs/NodeManagers.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
- [Project-wide opt-in via MSBuild property (legacy)](#project-wide-opt-in-via-msbuild-property-legacy)
- [Wiring callbacks: the Configure partial](#wiring-callbacks-the-configure-partial)
- [Addressing modes](#addressing-modes)
- [Creating nodes under other managers' nodes (Objects folder)](#creating-nodes-under-other-managers-nodes-objects-folder)
- [Typed model-traversal — the Configure(I{Manager}NodeManagerBuilder) partial](#typed-model-traversal--the-configureimanagernodemanagerbuilder-partial)
- [What the generator emits per model](#what-the-generator-emits-per-model)
- [Methods with arguments — typed OnCall overloads](#methods-with-arguments--typed-oncall-overloads)
Expand Down Expand Up @@ -758,9 +759,13 @@ namespace (legacy MSBuild mode) or the user class's namespace
`ValueTask<NodeStateCollection>`.
- `CreateAddressSpaceAsync` `await`s `base.CreateAddressSpaceAsync`,
then builds a fluent `INodeManagerBuilder`, invokes
`Configure(builder)`, calls `builder.Seal()`, and replays
`NotifyNodeAdded` for every predefined node so per-node lifecycle
hooks fire deterministically.
`Configure(builder)`, `await`s `CompleteConfigureAsync` (re-running
the reverse-reference pass so nodes created inside `Configure`
publish their references to nodes owned by other managers — e.g. an
inverse `Organizes` to the ns=0 `Objects` folder — into the
`externalReferences` dictionary), calls `builder.Seal()`, and
replays `NotifyNodeAdded` for every predefined node so per-node
lifecycle hooks fire deterministically.
- `AddPredefinedNodeAsync` / `RemovePredefinedNodeAsync` overrides
forward to base and then dispatch the lifecycle notification.
- `OnMonitoredItemCreated` (still synchronous on the base) dispatches
Expand Down Expand Up @@ -826,6 +831,24 @@ or by file stem:
Set `GenerateFactory = false` to suppress factory emission when you want
to ship a hand-written `IAsyncNodeManagerFactory`.

When the manager also owns namespaces beyond the model's own — most
commonly a separate *instance* namespace for nodes created at runtime —
declare them on the attribute:

```csharp
[NodeManager(
NamespaceUri = Namespaces.Boiler,
AdditionalNamespaceUris = new[] { Namespaces.Boiler + "Instance" })]
```

The generated constructor passes them to the base manager together with
the model namespace and the generated factory advertises them in
`NamespacesUris`, so the master node manager routes requests for those
namespaces to this manager from the moment it is built. Calling
`SetNamespaces` later is not sufficient — the master builds its
namespace routing from what the manager reported when it was
constructed.

#### Project-wide opt-in via MSBuild property (legacy)

If you prefer a generator-derived class identity (`{Prefix}NodeManager` /
Expand Down Expand Up @@ -929,6 +952,47 @@ against the in-memory predefined-node tree. There is no reflection, no
`Activator.CreateInstance`, no `Expression.Compile` — the whole pipeline
is NativeAOT-safe.

#### Creating nodes under other managers' nodes (Objects folder)

Anything a NodeSet can declare, `Configure` can declare identically —
including references whose target is owned by **another** node manager,
such as the ns=0 `Objects` folder managed by the `CoreNodeManager`.
Write the inverse reference on your node; after the `Configure`
partials return, the manager re-runs its reverse-reference pass
(`FluentNodeManagerBase.CompleteConfigureAsync`) and publishes the
matching forward edge into the `externalReferences` dictionary that the
master node manager distributes once every manager's address space is
built.

Two helpers make the common placement declarative, and a manager-level
`CreateInstance<TState>` creates root-level (parentless) instances,
materializing the subtree from the type model and minting NodeIds
through the manager's `INodeIdFactory`:

```csharp
partial void Configure(INodeManagerBuilder builder)
{
// Instantiate a second boiler at runtime and place it under the
// ns=0 Objects folder — no LoadPredefinedNodesAsync override, no
// manual externalReferences bookkeeping.
builder.CreateInstance(
new QualifiedName("Boiler #2", NamespaceIndexes[1]),
p => new BoilerState(p))
.Configure(n => n.UnderObjectsFolder());

// Arbitrary parents work the same way:
// n.OrganizedBy(parentNodeId)
// adds the inverse Organizes reference; the forward edge reaches
// the owning manager automatically.
}
```

Inverse `HasNotifier` references to external notifiers get root-notifier
registration through the same pass. This covers **startup-time**
configuration only — for nodes created after startup use
`IMasterNodeManager.AddReferencesAsync`, which dispatches to the live
owning manager.

### Typed model-traversal — the `Configure(I{Manager}NodeManagerBuilder)` partial

Alongside the string/NodeId/TypeId addressing surface above, the
Expand Down Expand Up @@ -1419,6 +1483,13 @@ builder.Node("Pumps/Pump #1/Operational/MyGroup")
.HasProperty(metadataNodeId);
```

`INodeBuilder.OrganizedBy(parentId)` and `.UnderObjectsFolder()` add
the *inverse* `Organizes` reference, placing the current node below an
organizing parent. The parent may be owned by another node manager
(e.g. the ns=0 `Objects` folder) — the forward edge is published
automatically when `Configure` completes; see
[Creating nodes under other managers' nodes](#creating-nodes-under-other-managers-nodes-objects-folder).

`INodeBuilder.AddObject(browseName, typeDefinitionId)` synthesises a
new `BaseObjectState` child under the current node and returns a typed
builder for the new object. NodeIds follow the
Expand Down Expand Up @@ -1470,6 +1541,22 @@ is used by the fluent state-machine creators, so generated instances
created from a builder can be browsed, read, and monitored without a
separate `AddPredefinedNodeAsync` call.

For **root-level** instances (no parent node in this manager),
`INodeManagerBuilder.CreateInstance<TState>(name, factory)` accepts a
plain constructor-style factory (`p => new BoilerState(p)`, invoked
with a `null` parent), fully materialises the instance from its type
model, and mints per-instance NodeIds for the whole subtree through
the manager's `INodeIdFactory`. Combine with `.UnderObjectsFolder()`
(or `.OrganizedBy(parentId)`) to place the instance below a node owned
by another manager:

```csharp
builder.CreateInstance(
new QualifiedName("Boiler #2", NamespaceIndexes[1]),
p => new BoilerState(p))
.Configure(n => n.UnderObjectsFolder());
```

#### Alarm setup (MVP)

`INodeBuilder.CreateLimitAlarm`, `.CreateExclusiveLimitAlarm` and
Expand Down
36 changes: 36 additions & 0 deletions src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -228,6 +229,41 @@ public void AttachToBuilder(NodeManagerBuilder builder)
builder.AttachSimulations(Simulations);
}

/// <summary>
/// Re-runs the reverse-reference collection pass after the user's
/// <c>Configure</c> callbacks return so that nodes registered
/// during <c>Configure</c> publish their references to nodes
/// owned by other node managers (e.g. an inverse
/// <c>Organizes</c> reference to the Objects folder) into
/// <paramref name="externalReferences"/>, exactly like nodes
/// declared in a NodeSet do. Inverse <c>HasNotifier</c>
/// references to external notifiers additionally register the
/// source as a root notifier.
/// </summary>
/// <remarks>
/// The source-generated <c>CreateAddressSpaceAsync</c> and the
/// hosting <c>FluentNodeManager</c> invoke this once between the
/// <c>Configure</c> callbacks and <see cref="NodeManagerBuilder.Seal"/>;
/// hand-written managers that drive
/// <see cref="CreateFluentBuilder"/> themselves should do the
/// same. Timing is safe because the master node manager
/// distributes <paramref name="externalReferences"/> only after
/// every manager's <c>CreateAddressSpaceAsync</c> has returned.
/// The pass is idempotent, so running it after an earlier
/// <c>LoadPredefinedNodesAsync</c> sweep adds no duplicates.
/// </remarks>
/// <param name="externalReferences">
/// The dictionary of references to add to external targets that
/// was handed to <c>CreateAddressSpaceAsync</c>.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
protected ValueTask CompleteConfigureAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
return AddReverseReferencesAsync(externalReferences, cancellationToken);
}

/// <summary>
/// Signals the registry whenever a notifier's monitored-events
/// ref-count flips so the reconcile loop can start or stop the
Expand Down
125 changes: 124 additions & 1 deletion src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,108 @@ public static IInstanceBuilder<TState> CreateInstance<TState>(

return new InstanceBuilder<TState>(parent, instance);
}

/// <summary>
/// Creates a new root-level (parentless) instance of
/// <typeparamref name="TState"/> and registers it with the
/// owning node manager.
/// </summary>
/// <remarks>
/// <para>
/// Unlike the parent-scoped
/// <see cref="CreateInstance{TState}(INodeBuilder, QualifiedName, Func{NodeState, TState})"/>,
/// this overload fully materializes the instance from its type
/// model (mandatory children, references, default values) via
/// <see cref="NodeState.Create(ISystemContext, NodeId, QualifiedName, LocalizedText, bool)"/>
/// and mints per-instance NodeIds for the whole subtree through
/// the manager's <see cref="INodeIdFactory"/>, so the factory
/// delegate should be a plain constructor call:
/// </para>
/// <code>
/// builder.CreateInstance(
/// new QualifiedName("Boiler #2", instanceNamespaceIndex),
/// p => new BoilerState(p))
/// .Configure(n => n.UnderObjectsFolder());
/// </code>
/// <para>
/// Place the new instance below a node owned by another node
/// manager (e.g. the Objects folder) by adding an inverse
/// hierarchical reference — for example via
/// <see cref="ReferenceBuilderExtensions.UnderObjectsFolder(INodeBuilder)"/>
/// or <see cref="ReferenceBuilderExtensions.OrganizedBy(INodeBuilder, NodeId)"/>;
/// the forward edge is published to the owning manager at the
/// end of <c>Configure</c>.
/// </para>
/// </remarks>
/// <typeparam name="TState">Concrete instance state class.</typeparam>
/// <param name="builder">The owning node-manager builder.</param>
/// <param name="browseName">Browse name of the new instance.</param>
/// <param name="factory">
/// Factory that constructs the (uninitialized) state instance,
/// typically <c>p => new BoilerState(p)</c>. It receives
/// <see langword="null"/> as the parent.
/// </param>
/// <returns>A typed instance builder for further configuration.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="builder"/>, <paramref name="browseName"/>, or
/// <paramref name="factory"/> is <c>null</c>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// The builder's context does not supply an
/// <see cref="INodeIdFactory"/> to mint instance NodeIds.
/// </exception>
/// <exception cref="ServiceResultException">
/// The factory returned <c>null</c>.
/// </exception>
public static IInstanceBuilder<TState> CreateInstance<TState>(
this INodeManagerBuilder builder,
QualifiedName browseName,
Func<NodeState?, TState> factory)
where TState : BaseInstanceState
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (browseName.IsNull)
{
throw new ArgumentNullException(nameof(browseName));
}
if (factory == null)
{
throw new ArgumentNullException(nameof(factory));
}

// Fail fast: without a NodeIdFactory the subtree would keep
// its type-declaration NodeIds and collide with the type
// model on registration.
builder.Context.RequireNodeIdFactory();

TState instance = factory(null) ??
throw ServiceResultException.Create(
StatusCodes.BadInvalidArgument,
"Factory returned null for instance '{0}'.",
browseName);

// Materialize the subtree from the type model. The children
// carry type-declaration NodeIds at this point.
instance.Create(
builder.Context,
NodeId.Null,
browseName,
displayName: default,
assignNodeIds: false);

// Rebase the whole subtree onto per-instance NodeIds minted
// by the manager's INodeIdFactory (same pass the generated
// CreateXxx(parent, browseName) factories run).
NodeId previousNodeId = builder.Context.AssignInstanceNodeId(instance);
builder.Context.AssignInstanceChildNodeIds(instance, previousNodeId);

FluentNodeRegistration.RegisterCreatedNode(builder, instance);

return new InstanceBuilder<TState>(builder, instance);
}
}

/// <summary>
Expand All @@ -162,7 +264,11 @@ public interface IInstanceBuilder<TState>
TState Node { get; }

/// <summary>
/// The owning parent builder.
/// The owning parent builder. For a root-level instance created
/// via the manager-scoped
/// <see cref="InstanceCreationBuilderExtensions.CreateInstance{TState}(INodeManagerBuilder, QualifiedName, Func{NodeState, TState})"/>
/// overload there is no parent node, so this is a builder view
/// of the instance itself.
/// </summary>
INodeBuilder Parent { get; }

Expand All @@ -186,6 +292,8 @@ public interface IInstanceBuilder<TState>

/// <summary>
/// Returns control to the parent builder for further chaining.
/// For a root-level instance this is a builder view of the
/// instance itself; see <see cref="Parent"/>.
/// </summary>
INodeBuilder Done();
}
Expand All @@ -206,6 +314,21 @@ public InstanceBuilder(INodeBuilder parent, TState node)
Node = node ?? throw new ArgumentNullException(nameof(node));
}

/// <summary>
/// Root-level (parentless) variant: the instance was created at
/// manager scope, so <see cref="Parent"/> is a builder view of
/// the instance itself.
/// </summary>
public InstanceBuilder(INodeManagerBuilder owner, TState node)
{
if (owner == null)
{
throw new ArgumentNullException(nameof(owner));
}
Node = node ?? throw new ArgumentNullException(nameof(node));
Parent = new AdHocInstanceNodeBuilder<TState>(owner, node);
}

public TState Node { get; }
public INodeBuilder Parent { get; }

Expand Down
17 changes: 17 additions & 0 deletions src/Opc.Ua.Server/Fluent/NodeManagerAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,22 @@ public sealed class NodeManagerAttribute : Attribute
/// author the factory by hand.
/// </summary>
public bool GenerateFactory { get; set; } = true;

/// <summary>
/// Additional namespace URIs the manager owns beyond the model's
/// own namespace — typically a separate instance namespace (e.g.
/// <c>"http://opcfoundation.org/UA/Boiler/Instance"</c>).
/// </summary>
/// <remarks>
/// The generated constructor passes these to the base node
/// manager together with the model namespace, so the master node
/// manager routes requests for them to this manager from the
/// moment it is built. Reporting an extra namespace later via
/// <c>SetNamespaces</c> is not sufficient, because the master
/// builds its namespace routing from what the manager reported
/// at construction. The generated factory advertises the same
/// set through <c>NamespacesUris</c>.
/// </remarks>
public string[] AdditionalNamespaceUris { get; set; } = null!;
}
}
Loading
Loading